From e38764627bf12b1de554cab6e37a249c508db828 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:20:42 +0100 Subject: [PATCH] feat: remove installed files and list backups --remove deletes for good, or moves into a backup with --backup, only what the install record proves the installer placed: unchanged bytes, a regular file below the BIOS folder reached through no link, a SHA-1 the list still serves. Shared, changed, hand-added and unservable files stay and are named. --backups lists, --delete-backup deletes, --restore now updates the record. The full screen gains More, Remove files, the removal plan, Backups and their confirms. Events removal_plan, removed, restored and backup. --- install.ps1 | 2 +- install.py | 1390 ++++++++++++++++- install.sh | 2 +- install/launchers/index.json | 16 +- schemas/install-events.schema.json | 218 ++- scripts/generate_event_schema.py | 13 +- tests/fixtures/tui/backups-120x40.txt | 40 + tests/fixtures/tui/backups-40x12.txt | 12 + tests/fixtures/tui/backups-40x15.txt | 15 + tests/fixtures/tui/backups-80x24.txt | 24 + tests/fixtures/tui/colour-120x40.txt | 248 ++- tests/fixtures/tui/colour-40x12.txt | 80 +- tests/fixtures/tui/colour-40x15.txt | 98 +- tests/fixtures/tui/colour-80x24.txt | 152 +- tests/fixtures/tui/more-120x40.txt | 40 + tests/fixtures/tui/more-40x12.txt | 12 + tests/fixtures/tui/more-40x15.txt | 15 + tests/fixtures/tui/more-80x24.txt | 24 + tests/fixtures/tui/plan-record-120x40.txt | 2 +- tests/fixtures/tui/plan-record-40x12.txt | 2 +- tests/fixtures/tui/plan-record-40x15.txt | 2 +- tests/fixtures/tui/plan-record-80x24.txt | 2 +- tests/fixtures/tui/removal-120x40.txt | 40 + tests/fixtures/tui/removal-40x12.txt | 12 + tests/fixtures/tui/removal-40x15.txt | 15 + tests/fixtures/tui/removal-80x24.txt | 24 + tests/fixtures/tui/removal-confirm-120x40.txt | 40 + tests/fixtures/tui/removal-confirm-40x12.txt | 12 + tests/fixtures/tui/removal-confirm-40x15.txt | 15 + tests/fixtures/tui/removal-confirm-80x24.txt | 24 + tests/fixtures/tui/removal-done-120x40.txt | 40 + tests/fixtures/tui/removal-done-40x12.txt | 12 + tests/fixtures/tui/removal-done-40x15.txt | 15 + tests/fixtures/tui/removal-done-80x24.txt | 24 + tests/fixtures/tui/remove-120x40.txt | 40 + tests/fixtures/tui/remove-40x12.txt | 12 + tests/fixtures/tui/remove-40x15.txt | 15 + tests/fixtures/tui/remove-80x24.txt | 24 + tests/fixtures/tui/route-evmapy.txt | 330 +++- tests/fixtures/tui/route-gptokeyb.txt | 330 +++- tests/fixtures/tui/route-gptokeyb_muxterm.txt | 330 +++- tests/fixtures/tui/route-keys.txt | 330 +++- tests/fixtures/tui/route-miyoo.txt | 330 +++- tests/fixtures/tui/route-muxterm.txt | 330 +++- tests/test_install_journeys.py | 6 +- tests/test_install_removal.py | 470 ++++++ tests/test_install_tui.py | 204 ++- 47 files changed, 5334 insertions(+), 99 deletions(-) create mode 100644 tests/fixtures/tui/backups-120x40.txt create mode 100644 tests/fixtures/tui/backups-40x12.txt create mode 100644 tests/fixtures/tui/backups-40x15.txt create mode 100644 tests/fixtures/tui/backups-80x24.txt create mode 100644 tests/fixtures/tui/more-120x40.txt create mode 100644 tests/fixtures/tui/more-40x12.txt create mode 100644 tests/fixtures/tui/more-40x15.txt create mode 100644 tests/fixtures/tui/more-80x24.txt create mode 100644 tests/fixtures/tui/removal-120x40.txt create mode 100644 tests/fixtures/tui/removal-40x12.txt create mode 100644 tests/fixtures/tui/removal-40x15.txt create mode 100644 tests/fixtures/tui/removal-80x24.txt create mode 100644 tests/fixtures/tui/removal-confirm-120x40.txt create mode 100644 tests/fixtures/tui/removal-confirm-40x12.txt create mode 100644 tests/fixtures/tui/removal-confirm-40x15.txt create mode 100644 tests/fixtures/tui/removal-confirm-80x24.txt create mode 100644 tests/fixtures/tui/removal-done-120x40.txt create mode 100644 tests/fixtures/tui/removal-done-40x12.txt create mode 100644 tests/fixtures/tui/removal-done-40x15.txt create mode 100644 tests/fixtures/tui/removal-done-80x24.txt create mode 100644 tests/fixtures/tui/remove-120x40.txt create mode 100644 tests/fixtures/tui/remove-40x12.txt create mode 100644 tests/fixtures/tui/remove-40x15.txt create mode 100644 tests/fixtures/tui/remove-80x24.txt create mode 100644 tests/test_install_removal.py diff --git a/install.ps1 b/install.ps1 index f4d16af0a..93d937a5a 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 = "67036bbe4db401de213d9a8ab44161b8b64bfde91834b438fff47802b5a461aa" + $defaultInstallSha256 = "ce18b5e213fde35341231cd79a9c9205f57f3f096827dc50e26db9487d1b5349" # 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 529bf6664..f466236bd 100644 --- a/install.py +++ b/install.py @@ -685,7 +685,8 @@ class Progress: # exactly these keys besides "event", in this order, and nothing else. EVENT_SCHEMA = 1 EVENT_KEYS: dict[str, tuple[str, ...]] = { - # mode: install, check, dry-run or list. + # mode: install, check, dry-run, list, remove (--remove, --delete-backup) or + # restore. "start": ("schema", "installer", "mode"), # A folder detection could not look in, once each, before detect: the # probes under it counted as absent and the others went on. reason is @@ -823,6 +824,24 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = { # 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"), + # --remove, before anything is removed (with --dry-run, all it writes): + # the files that go (dests, in record order), their bytes, the systems + # losing a file, kept [{"dest", "reason"}] for each file left (reason + # one of REMOVAL_REASONS), gone the recorded files no longer there, + # backup whether they are moved rather than deleted. message says it. + # Written with files 0 when no record says what is the installer's. + "removal_plan": ("platform", "folder", "files", "bytes", "systems", "dests", "kept", + "gone", "backup", "message"), + # Each file --remove took: ok false with reason when it stays, moved_to + # the file's place in the backup with --backup, else "". + "removed": ("platform", "dest", "size", "ok", "reason", "moved_to"), + # Each file --restore put back from the backup folder to path. + "restored": ("folder", "path", "size", "ok", "reason"), + # A backup folder: listed by --backups, made by --remove --backup, or + # deleted (not_deleted when a file stayed) by --delete-backup. date is + # when it was made (local time), source remove for a removal's, install + # for the files an install replaced. + "backup": ("folder", "date", "files", "bytes", "source", "status", "message"), "end": ("code",), } # Seconds between two progress events while bytes arrive. @@ -863,6 +882,12 @@ EVENTS = EventStream() def _event_mode(args: argparse.Namespace) -> str: + if getattr(args, "remove", False) or getattr(args, "delete_backup", None) is not None: + return "remove" + if getattr(args, "restore", None) is not None: + return "restore" + if getattr(args, "backups", False): + return "list" if args.check: return "check" if args.dry_run: @@ -6394,6 +6419,15 @@ def write_record( } data = {"schema": RECORD_SCHEMA, "installer": installer_id(), "folder": str(folder), "setups": {**data["setups"], plat: setup}} + where, failure = _save_record(folder, data, found) + if where is None: + return None, setup, f"The install record could not be written ({failure})" + return where, setup, "" + + +def _save_record(folder: Path, data: dict, found: Path | None) -> tuple[Path | None, str]: + """Write a record's data where it was read, else where it goes: (where, + why nothing could be written).""" text = json.dumps(data, separators=(",", ":"), sort_keys=True) + "\n" places = record_paths(folder) if found is not None and found in places: @@ -6419,8 +6453,8 @@ def write_record( 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})" + return path, "" + return None, failure def record_menu_words(setup: dict | None) -> str: @@ -6514,63 +6548,842 @@ def apply_record(args: argparse.Namespace, plat: str, folder: Path) -> dict | No return setup -def restore_kept(run: Path, dry_run: bool = False) -> int: - """Put every file of a backup run folder back where it was. +def _is_backup_run(run: Path) -> bool: + """Whether run is a dated folder a run of the installer made: a real + folder holding the marker, inside a retrobios-backup holder that is no + link.""" + try: + info, holder = os.lstat(run), os.lstat(run.parent) + marker = os.lstat(run / BACKUP_MARKER) + except OSError: + return False + return (stat.S_ISDIR(info.st_mode) and stat.S_ISDIR(holder.st_mode) + and run.parent.name == BACKUP_DIR and stat.S_ISREG(marker.st_mode)) - run is a / folder a run wrote, its holder named - retrobios-backup. Each file goes to the same path below the holder's - parent, replacing what is there. The backup is left in place. Returns - the exit code: 0 when every file went back, 1 when one failed, 2 when - the folder is no backup. - """ - run = Path(os.path.abspath(os.path.expanduser(str(run)))) - if (not run.is_dir() or run.is_symlink() or run.parent.name != BACKUP_DIR - or run.parent.is_symlink() or not (run / BACKUP_MARKER).is_file()): - print(f"Error: {run} is not a folder the installer kept files in.", file=sys.stderr) - print(f" Name one of the dated folders inside a {BACKUP_DIR} folder, as the " - "end of the install printed it.", file=sys.stderr) - return 2 - root = run.parent.parent - # Links inside the backup are never followed: os.walk does not descend - # into a linked folder and a linked file is skipped. - files = sorted( + +def _backup_files(run: Path) -> list[Path]: + """The files a backup run folder holds, its marker and note aside. Links + inside it are never followed: os.walk does not descend into a linked + folder, and a linked file is skipped.""" + return sorted( Path(base) / name for base, _dirs, names in os.walk(run, followlinks=False) for name in names if not (Path(base) / name).is_symlink() and not (name.startswith(".") and name.endswith(".part")) - and not (Path(base) == run and name == BACKUP_MARKER) + and not (Path(base) == run and name in (BACKUP_MARKER, REMOVED_NOTE)) ) + + +def _backup_note(run: Path) -> dict | None: + """What a removal wrote beside the files it moved into run: the setup, + its folder, its choice before and the record's entries. None for the + backup of files an install replaced, or a note that cannot be read.""" + try: + data = json.loads(_read_record_bytes(run / REMOVED_NOTE).decode("utf-8")) + except (OSError, UnicodeDecodeError, ValueError): + return None + if not isinstance(data, dict) or not isinstance(data.get("platform"), str) \ + or not isinstance(data.get("folder"), str): + return None + setup = {"written": "", "choice": data.get("choice"), "files": data.get("files")} + return data if _valid_setup(setup, Path(data["folder"])) else None + + +def _record_folder(run: Path, path: Path) -> tuple[Path, str] | None: + """The BIOS folder whose record may name a file put back at path, and + its dest there. The holder beside a folder keeps the folder's name as + the first level of each run, the one inside it none.""" + holder, root = run.parent, run.parent.parent + rel = PurePosixPath(*path.relative_to(root).parts) + if len(rel.parts) > 1 and (holder / f"{rel.parts[0]}{RECORD_SUFFIX}").is_file(): + return root / rel.parts[0], PurePosixPath(*rel.parts[1:]).as_posix() + if (holder / f"{root.name or 'folder'}{RECORD_SUFFIX}").is_file(): + return root, rel.as_posix() + return None + + +def _record_after_restore(run: Path, put_back: list[Path]) -> str: + """The record of the folder files went back into, made to say what is + there now: "" once written, else why not. A removal's backup gives its + files back to their setup, with the choice it had. Any other backup + holds the user's own files, which the record never names: its entries + at their places go.""" + note = _backup_note(run) + if note is not None: + folder = Path(note["folder"]) + back = [] + for item in note["files"]: + path = _below(folder, item["dest"])[0] + if path is not None and not _check_ours(path, item)[0]: + back.append(item) + if not back: + return "" + return _update_record(folder, note["platform"], + lambda setup: _record_merge(setup, back, note["choice"])) + by_folder: dict[Path, set[str]] = {} + for path in put_back: + found = _record_folder(run, path) + if found is not None: + by_folder.setdefault(found[0], set()).add(found[1].casefold()) + problems = [] + for folder, gone in by_folder.items(): + read = load_record(folder) + for plat in sorted((read.data or {}).get("setups") or {}): + problems.append(_update_record(folder, plat, lambda setup: None if setup is None else { + **setup, "files": [item for item in setup["files"] + if str(item.get("root") or "") not in ("", "data") + or item["dest"].casefold() not in gone]})) + return next((problem for problem in problems if problem), "") + + +def _record_merge(setup: dict | None, files: list[dict], choice: list[str]) -> dict: + """A setup with files given back and the choice it had before a removal.""" + merged = {(str(item.get("root") or ""), item["dest"]): item + for item in (setup or {}).get("files") or []} + for item in files: + merged[(str(item.get("root") or ""), item["dest"])] = item + return {"list": {"version": None, "generated": None}, **(setup or {}), + "written": _record_now(), "choice": list(choice), + "files": [merged[key] for key in sorted(merged)]} + + +def _record_now() -> str: + return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _update_record(folder: Path, plat: str, + change: Callable[[dict | None], dict | None]) -> str: + """Rewrite the setup plat of folder's record as change makes it, None + taking it out: "" once written, else why not. A record that cannot be + read, or that a newer installer wrote, is left as it is.""" + folder = Path(os.path.abspath(folder)) + read = load_record(folder) + if read.newer or (read.data is None and read.path is not None): + return read.problem + data = read.data or {"setups": {}, "folder": str(folder)} + setups = dict(data["setups"]) + new = change(setups.get(plat)) + if new is None: + setups.pop(plat, None) + else: + setups[plat] = new + if not setups: + if read.path is None: + return "" + # Nothing of ours is left in the folder: the record goes with it. + try: + read.path.unlink() + except OSError as exc: + return exc.strerror or str(exc) + return "" + data = {"schema": RECORD_SCHEMA, "installer": installer_id(), + "folder": str(data.get("folder") or folder), "setups": setups} + where, failure = _save_record(folder, data, read.path) + return "" if where is not None else failure + + +def restore_kept(run: Path, dry_run: bool = False) -> int: + """Put every file of a backup run folder back where it was. + + run is a / folder a run wrote, its holder named + retrobios-backup. Each file goes to the same path below the holder's + parent, replacing what is there, and the install record of its folder + is made to say so. The backup is left in place. Returns the exit code: + 0 when every file went back, 1 when one failed, 2 when the folder is no + backup. + """ + run = Path(os.path.abspath(os.path.expanduser(str(run)))) + if not _is_backup_run(run): + print(f"Error: {run} is not a folder the installer kept files in.", file=sys.stderr) + print(f" Name one of the dated folders inside a {BACKUP_DIR} folder, as " + "--backups lists them.", file=sys.stderr) + return 2 + root = run.parent.parent + files = _backup_files(run) if not files: print(f"{run} holds no file. Nothing to put back.") return 0 restored = size = failed = 0 + put_back: list[Path] = [] for src in files: dest = root / src.relative_to(run) + length = src.stat().st_size if dry_run: print(f" would put back {dest}") restored += 1 - size += src.stat().st_size + size += length continue try: dest.parent.mkdir(parents=True, exist_ok=True) _clear_read_only(dest) _replace_with_copy(src, dest) except OSError as exc: - print(f" failed: {dest} ({exc.strerror or exc})", file=sys.stderr) + reason = exc.strerror or str(exc) + print(f" failed: {dest} ({reason})", file=sys.stderr) + EVENTS.emit("restored", folder=str(run), path=str(dest), size=length, ok=False, + reason=reason) failed += 1 continue + EVENTS.emit("restored", folder=str(run), path=str(dest), size=length, ok=True, + reason="") + put_back.append(dest) restored += 1 - size += src.stat().st_size + size += length if dry_run: print(f"\nWould put back {_plural(restored, 'file')} ({format_size(size)}) " f"under {root}. Nothing was written.") return 0 + problem = _record_after_restore(run, put_back) print(f"\nPut back {_plural(restored, 'file')} ({format_size(size)}) under {root}.") if failed: print(f"{_plural(failed, 'file')} could not be put back.", file=sys.stderr) - print(f"The copies are still in {run}. That folder can be deleted once it is no longer needed.") - return 1 if failed else 0 + if problem: + print(f"The install record could not be updated ({problem}).", file=sys.stderr) + print(f"The copies are still in {run}. To free the space: {_INVOCATION.launcher()} " + f"--delete-backup {_INVOCATION.word(str(run))}") + return 1 if failed or problem else 0 + + +# Removal deletes for good, or moves into a backup with --backup, only what +# the install record proves the installer placed: a file it lists, still +# its recorded bytes, the collection able to serve it again. Anything else +# is named and left. Why each file stays, as removal_plan's kept says it. +REMOVAL_REASONS = { + "shared": "still needed", + "changed": "changed since it was installed", + "link": "a link, never followed", + "not_a_file": "not a regular file", + "outside": "outside the BIOS folder", + "not_servable": "the collection cannot give it again", + "unreadable": "could not be read", + "not_recorded": "not installed by RetroBIOS", +} +# Written into a backup that a removal made: the setup, its folder, its +# choice before and the record's entries, so --restore gives them back. +REMOVED_NOTE = ".retrobios-removed.json" + + +class Removal(NamedTuple): + """What a removal takes and leaves. remove holds {dest, root, sha1, + size, systems, path, inode} per file to remove, kept {dest, root, sha1, + size, systems, reason, what} per file left, gone the record's entries + no longer on disk, choice the setup's choice once the removal is done + (None: the setup goes from the record), systems those losing a file.""" + + plat: str + folder: Path + remove: list + kept: list + gone: list + choice: list | None + systems: list + + +def _entry_sha1s(entry: dict) -> set[str]: + return {str(copy.get("sha1")) for copy in [entry, *(entry.get("alternatives") or []), + *(entry.get("slot_choices") or [])] + if copy.get("sha1")} + + +def _entry_readers(entry: dict) -> set[str] | None: + """The cores that read an entry, None for a file of the platform's own list.""" + if entry.get("cores") is None: + return None + return {core for copy in [entry, *(entry.get("alternatives") or []), + *(entry.get("slot_choices") or [])] + for core in copy.get("cores") or ()} + + +def _served(manifest: dict, fold: bool) -> dict[tuple[str, str], tuple]: + """What the list serves, by (root, dest): (the sha1s it serves there, + its systems, its readers). A data tree's member counts under root data, + as the record keeps it.""" + served: dict[tuple[str, str], tuple] = {} + for entry in [*(manifest.get("files") or []), *(manifest.get("case_variants") or [])]: + dest = str(entry.get("dest")) + place = (str(entry.get("root") or ""), dest.casefold() if fold else dest) + sha1s, systems, readers = served.get(place, (set(), [], None)) + mine = _entry_readers(entry) + served[place] = (sha1s | _entry_sha1s(entry), + sorted(set(systems) | set(entry_systems(entry))), + mine if readers is None else readers | (mine or set())) + for tree in manifest.get("data_directories") or []: + for rel, meta in (tree.get("files") or {}).items(): + dest = f"{tree['dest']}/{rel}" if tree.get("dest") else rel + served[("data", dest.casefold() if fold else dest)] = ( + {str(meta.get("sha1"))}, list(tree.get("systems") or []), None) + return served + + +def _below(folder: Path, dest: str) -> tuple[Path | None, str]: + """The file a record's dest names below folder, reached through no link: + (path, "") or (None, why it is left). A dest that climbs or is absolute + is outside, whatever a record says.""" + try: + rel = _safe_relative_path(dest, "dest") + except ValueError: + return None, "outside" + path = folder + for part in rel.parts[:-1]: + path = path / part + try: + info = os.lstat(path) + except FileNotFoundError: + return None, "gone" + except OSError: + return None, "unreadable" + if stat.S_ISLNK(info.st_mode): + return None, "link" + if not stat.S_ISDIR(info.st_mode): + return None, "not_a_file" + return path / rel.parts[-1], "" + + +def _check_ours(path: Path, item: dict) -> tuple[str, os.stat_result | None]: + """Why the file at path is not the one the record names, "" when it + is, and what lstat read of it.""" + try: + info = os.lstat(path) + except FileNotFoundError: + return "gone", None + except OSError: + return "unreadable", None + if stat.S_ISLNK(info.st_mode): + return "link", info + if not stat.S_ISREG(info.st_mode): + return "not_a_file", info + if not re.fullmatch(r"[0-9a-f]{40}", str(item["sha1"])) or info.st_size != item["size"]: + return "changed", info + try: + same = _digest_file(path, ("sha1",))["sha1"] == item["sha1"] + except OSError: + return "unreadable", info + return ("" if same else "changed"), info + + +def _choice_names(options: list[tuple[str, str | None]], flag: str) -> list[str]: + return [name.strip() for option, value in options if option == flag + for name in (value or "").split(",") if name.strip()] + + +def _choice_without(choice: list[str], flag: str, names: Collection[str], + left: list[str]) -> list[str]: + """A recorded choice once names of flag are taken out: those it named + go, and a choice that named none gets the names left. A flag left with + no name is dropped.""" + options = [option for option in record_options(choice) if option[0] != "--all-files"] + gone = {name.casefold() for name in names} + remaining = [name for name in (_choice_names(options, flag) or left) + if name.casefold() not in gone] + kept = [option for option in options if option[0] != flag] + if remaining: + kept.append((flag, ",".join(remaining))) + return record_argv(kept) + + +def plan_removal( + plat: str, folder: Path, record: dict, manifest: dict, *, + systems: Collection[str] = (), cores: Collection[str] = (), + emulators: Collection[str] = (), files: Collection[str] = (), +) -> Removal: + """What removing the chosen part of a setup takes and leaves. + + Only the record of plat in folder says what may go: every file it + lists, or those of systems, of cores (or of the emulators' cores), or + the dests in files. A file still needed by what stays (a system or a + core still chosen, another setup of the same folder) stays, and so do a + file changed since, a link, anything that is not a regular file, any + file outside the BIOS folder, and any the list can no longer serve. A + drive that folds case compares dests folded, as FAT and exFAT open them. + """ + folder = Path(os.path.abspath(folder)) + setup = record["setups"][plat] + fold = not keeps_case(folder) + + def norm(dest: str) -> str: + return dest.casefold() if fold else dest + + served = _served(manifest, fold) + known = available_choices(list(manifest.get("files") or []), + list(manifest.get("omitted_files") or [])) + options = record_options(setup.get("choice") or []) + chosen_systems = set(systems) + chosen_cores = designated_cores(manifest, [*cores, *emulators]) if ( + cores or emulators) else set() + others = {(str(item.get("root") or ""), norm(item["dest"])): name + for name, other in record["setups"].items() if name != plat + for item in other.get("files") or []} + wanted = {norm(dest) for dest in files} + # What stays chosen once the removal is done, as an install names it: + # the systems or cores the record named, else every one of the list. + named_systems = {name.casefold() for name in _choice_names(options, "--system")} + staying_systems = {name for name in known["systems"] if not named_systems + or name.casefold() in named_systems} - chosen_systems + named_cores = _choice_names(options, "--core") + staying_cores = (designated_cores(manifest, named_cores) if named_cores + else set(known["cores"]) | set(manifest.get("core_names") or {}) + ) - chosen_cores + remove, kept, gone, picked = [], [], [], set() + for item in setup.get("files") or []: + root, dest = str(item.get("root") or ""), str(item["dest"]) + sha1s, item_systems, readers = served.get((root, norm(dest)), (set(), [], None)) + if chosen_systems and not (chosen_systems & set(item_systems) or ( + not item_systems and OTHER_SYSTEMS in chosen_systems)): + continue + if chosen_cores and not (readers and readers & chosen_cores): + continue + if files and norm(dest) not in wanted: + continue + picked.add(norm(dest)) + entry = {"dest": dest, "root": root, "sha1": item["sha1"], "size": item["size"], + "systems": item_systems} + if root not in ("", "data"): + kept.append({**entry, "reason": "outside", "what": ""}) + continue + path, why = _below(folder, dest) + info = None + if path is not None: + why, info = _check_ours(path, item) + if why == "gone": + gone.append(item) + continue + what = "" + if not why and item["sha1"] not in sha1s: + why = "not_servable" + if not why and (root, norm(dest)) in others: + why, what = "shared", display_name(others[(root, norm(dest))]) + if not why and chosen_systems and set(item_systems) & staying_systems: + why, what = "shared", ", ".join(system_title(name, manifest) for name in sorted( + set(item_systems) & staying_systems)) + if not why and chosen_cores and readers and readers & staying_cores: + why, what = "shared", ", ".join(sorted(readers & staying_cores)) + if why or path is None or info is None: + kept.append({**entry, "reason": why or "unreadable", "what": what}) + continue + remove.append({**entry, "path": path, "inode": (info.st_dev, info.st_ino)}) + kept += [{"dest": dest, "root": "", "sha1": "", "size": 0, "systems": [], + "reason": "not_recorded", "what": ""} + for dest in files if norm(dest) not in picked] + recorded: list[str] = list(setup.get("choice") or []) + choice: list | None = recorded + if files: + pass + elif chosen_systems: + choice = _choice_without(recorded, "--system", chosen_systems, + sorted(staying_systems | chosen_systems)) + if not _choice_names(record_options(choice), "--system"): + choice = None + elif chosen_cores and emulators and _choice_names(options, "--emulator"): + choice = _choice_without(recorded, "--emulator", emulators, []) + elif chosen_cores: + left = [] if named_cores else sorted(set(known["cores"]) - chosen_cores) + choice = _choice_without(recorded, "--core", chosen_cores | set(cores), left) + else: + choice = None + lost = sorted({name for entry in remove for name in entry["systems"]}) + return Removal(plat, folder, remove, kept, gone, choice, lost) + + +def _left_by_hand(folder: Path, record: dict, fold: bool) -> list[str]: + """The files in folder that no setup of its record names: placed by the + user or another program, never touched, counted in the summary.""" + named = {(item["dest"].casefold() if fold else item["dest"]) + for setup in record["setups"].values() for item in setup.get("files") or [] + if str(item.get("root") or "") in ("", "data")} + found = [] + for base, dirs, names in os.walk(folder, followlinks=False): + dirs[:] = sorted(name for name in dirs if name != BACKUP_DIR) + for name in sorted(names): + rel = (Path(base) / name).relative_to(folder).as_posix() + if (rel.casefold() if fold else rel) not in named and name != ".nomedia": + found.append(rel) + return found + + +def _kept_words(kept: list[dict]) -> str: + """Why files stay, counted by reason: 2 still needed, 1 changed since.""" + counts: dict[str, int] = {} + for item in kept: + counts[item["reason"]] = counts.get(item["reason"], 0) + 1 + order = list(REMOVAL_REASONS) + return ", ".join(f"{count} {REMOVAL_REASONS[reason]}" for reason, count in sorted( + counts.items(), key=lambda pair: (-pair[1], order.index(pair[0])))) + + +def kept_line(item: dict) -> str: + """One file a removal leaves, and why.""" + why = REMOVAL_REASONS[item["reason"]] + if item["reason"] == "shared" and item["what"]: + why += f" by {item['what']}" + return f"{item['dest']}: {why}" + + +def remove_files(removal: Removal, setup: dict, backup: bool + ) -> tuple[list[dict], list[dict], Path | None]: + """Delete each file of the plan, or with backup move it into a new + backup folder: (removed, failed {dest, reason}, the backup folder). Each + file is read again just before: one that changed kind or inode since + the plan stays.""" + removed, failed = [], [] + run: Path | None = None + under: Path | None = None + for entry in removal.remove: + path = entry["path"] + kept: Path | None = None + try: + info = os.lstat(path) + if (not stat.S_ISREG(info.st_mode) + or (info.st_dev, info.st_ino) != entry["inode"]): + raise OSError(errno.ESTALE, "changed since it was read") + if backup: + if run is None or under is None: + run, under = KEPT.place(removal.folder, create=True) + _write_removed_note(run, removal, setup) + kept = under / Path(*PurePosixPath(entry["dest"]).parts) + kept.parent.mkdir(parents=True, exist_ok=True) + _move_aside(path, kept) + else: + _clear_read_only(path) + path.unlink() + except OSError as exc: + reason = exc.strerror or str(exc) + failed.append({"dest": entry["dest"], "reason": reason}) + EVENTS.emit("removed", platform=removal.plat, dest=entry["dest"], + size=entry["size"], ok=False, reason=reason, moved_to="") + continue + removed.append(entry) + EVENTS.emit("removed", platform=removal.plat, dest=entry["dest"], + size=entry["size"], ok=True, reason="", + moved_to="" if kept is None else str(spelled(kept))) + return removed, failed, run + + +def _write_removed_note(run: Path, removal: Removal, setup: dict) -> None: + """What --restore needs to give the moved files back to their setup: + the record's entries of every file the plan moves, the choice before.""" + dests = {entry["dest"] for entry in removal.remove} + note = {"platform": removal.plat, "folder": str(removal.folder), + "choice": list(setup.get("choice") or []), + "files": [item for item in setup.get("files") or [] if item["dest"] in dests]} + (run / REMOVED_NOTE).write_text(json.dumps(note, sort_keys=True), encoding="utf-8") + + +def _record_after_removal(removal: Removal, removed: list[dict]) -> str: + """The setup's record without the files removed and those gone, with the + choice left: the setup goes once nothing of it is chosen or held.""" + taken = {(entry["root"], entry["dest"]) for entry in removed} | { + (str(item.get("root") or ""), item["dest"]) for item in removal.gone} + + def change(setup: dict | None) -> dict | None: + if setup is None: + return None + files = [item for item in setup.get("files") or [] + if (str(item.get("root") or ""), item["dest"]) not in taken] + if removal.choice is None or not files: + return None + return {**setup, "written": _record_now(), "choice": list(removal.choice), + "files": files} + + return _update_record(removal.folder, removal.plat, change) + + +def _answer_yes(prompt: str) -> bool: + """Yes only to y or yes: a removal is never the default answer.""" + return _ask(prompt).strip().lower() in ("y", "yes") + + +def _named_files(values: Iterable[Path], folder: Path) -> list[str] | None: + """The --file values as dests below folder, None for one outside it.""" + files = [] + for value in values: + given = Path(os.path.expanduser(str(value))) + if given.is_absolute(): + try: + given = Path(os.path.abspath(given)).relative_to(folder) + except ValueError: + print(f"Error: {value} is not inside {shown_path(folder)}: --file names a " + "file of the BIOS folder.", file=sys.stderr) + return None + files.append(PurePosixPath(*given.parts).as_posix()) + return files + + +def run_removal(args: argparse.Namespace) -> int: + """--remove: the plan, one confirmation, then each file deleted (or + moved with --backup), and the record made to say so. 0 done, 1 a file + or the record failed, 2 nothing done.""" + plat, folder = _action_setup(args) + read = load_record(folder) + setup, _path, problem = setup_record(plat, folder) + if setup is None or read.data is None: + why = problem or (f"its install record holds nothing for {display_name(plat)}" + if read.data is not None else + "no install record is beside it, so no file is known to be " + "the installer's") + message = f"Nothing was removed from {shown_path(folder)}: {why}." + print(message, file=sys.stderr) + EVENTS.emit("removal_plan", platform=plat, folder=str(folder), files=0, bytes=0, + systems=[], dests=[], kept=[], gone=0, backup=bool(args.backup), + message=message) + return 2 + manifest = fetch_manifest(plat) + every = list(manifest.get("files") or []) + omitted = list(manifest.get("omitted_files") or []) + known = available_choices(every, omitted) + try: + systems = resolve_choices(args.system, known["systems"], "system") \ + if args.system else [] + cores = resolve_choices(args.core, known["cores"], "core") if args.core else [] + emulators = resolve_choices(args.emulator, sorted(emulator_names( + manifest, every, omitted)), "emulator") if args.emulator else [] + except ValueError as exc: + refuse_name(plat, f"Error: {plat}: {exc}") + files = _named_files(args.file or [], folder) + if files is None: + return 2 + removal = plan_removal(plat, folder, read.data, manifest, systems=systems, cores=cores, + emulators=emulators, files=files) + size = sum(entry["size"] for entry in removal.remove) + whole = not (systems or cores or emulators or files) + by_hand = _left_by_hand(folder, read.data, not keeps_case(folder)) if whole else [] + where = f"{shown_path(folder)} for {display_name(plat, manifest)}" + if not removal.remove: + headline = f"Nothing to remove from {where}." + elif args.backup: + headline = (f"Move {_plural(len(removal.remove), 'file')} " + f"({format_size(size)}) from {where} to a backup.") + else: + headline = (f"Remove {_plural(len(removal.remove), 'file')} from {where}, " + f"{format_size(size)} freed.") + EVENTS.emit("removal_plan", platform=plat, folder=str(folder), files=len(removal.remove), + bytes=size, systems=removal.systems, + dests=[entry["dest"] for entry in removal.remove], + kept=[{"dest": item["dest"], "reason": item["reason"]} + for item in removal.kept], + gone=len(removal.gone), backup=bool(args.backup), message=headline) + print(headline) + if args.dry_run or args.verbose: + for entry in removal.remove: + print(f" {entry['dest']}") + if removal.kept: + print(f"{agree(len(removal.kept), 'file stays', 'files stay')}: " + f"{_kept_words(removal.kept)}.") + shown = removal.kept if args.verbose else removal.kept[:REMOVAL_KEPT_SHOWN] + for item in shown: + print(f" {kept_line(item)}") + if len(shown) < len(removal.kept): + print(f" and {len(removal.kept) - len(shown)} more (--verbose lists them)") + if by_hand: + print(f"{agree(len(by_hand), 'other file stays', 'other files stay')}, not " + "installed by RetroBIOS.") + if args.verbose: + for rel in by_hand: + print(f" {rel}") + if not removal.remove: + if removal.gone and not args.dry_run: + _record_after_removal(removal, []) + return 2 + if args.dry_run: + print("Nothing was removed (--dry-run).") + return 0 + if not args.yes: + if args.no_input or not sys.stdin.isatty(): + print("Error: removing files needs a yes: add --yes to remove them without " + "a question, or --dry-run to list them.", file=sys.stderr) + return 2 + question = ("Move them to a backup? [y/N] " if args.backup + else "Remove them for good? [y/N] ") + if not _answer_yes(question): + print("Nothing was removed.") + return QUIT_CODE + KEPT.reset() + removed, failed, run = remove_files(removal, setup, bool(args.backup)) + problem = _record_after_removal(removal, removed) + freed = sum(entry["size"] for entry in removed) + if run is not None: + print(f"Moved {_plural(len(removed), 'file')} ({format_size(freed)}) to " + f"{shown_path(run)}.") + print(f"To put them back: {_INVOCATION.launcher()} --restore " + f"{_INVOCATION.word(str(spelled(run)))}") + EVENTS.emit("backup", folder=str(spelled(run)), date=_backup_date(run), + files=len(removed), bytes=freed, source="remove", status="made", + message=f"Moved {_plural(len(removed), 'file')} here.") + elif removed: + print(f"Removed {_plural(len(removed), 'file')}, {format_size(freed)} freed.") + for item in failed: + print(f" failed: {item['dest']} ({item['reason']})", file=sys.stderr) + if problem: + print(f"The install record could not be updated ({problem}).", file=sys.stderr) + return 1 if failed or problem else 0 + + +# The files a removal leaves named one by one by default: past this, a +# count and --verbose. +REMOVAL_KEPT_SHOWN = 10 + + +def _backup_date(run: Path) -> str: + """When a run folder was made, read from its name: 2026-11-03 12:00:00.""" + match = re.match(r"(\d{4})(\d\d)(\d\d)-(\d\d)(\d\d)(\d\d)", run.name) + if not match: + return "" + year, month, day, hour, minute, second = match.groups() + return f"{year}-{month}-{day} {hour}:{minute}:{second}" + + +def backup_words(date: str) -> str: + """A backup's date as a list says it: 3 Nov 12:00.""" + try: + when = datetime.datetime.strptime(date, "%Y-%m-%d %H:%M:%S") + except ValueError: + return date or "unknown date" + day = f"{when.day} {_MONTHS[when.month - 1]}" + if when.year != datetime.datetime.now().year: + day += f" {when.year}" + return f"{day} {when.hour:02d}:{when.minute:02d}" + + +def list_backups(folder: Path) -> list[dict]: + """The backups of a BIOS folder, newest first: {folder, date, files, + bytes, source}, source remove for a removal's and install for the files + an install replaced. Only dated folders holding the marker count.""" + folder = Path(os.path.abspath(folder)) + found = [] + for holder, prefix in KeptFiles._holders(folder): + try: + if not stat.S_ISDIR(os.lstat(holder).st_mode): + continue + runs = sorted(holder.iterdir()) + except OSError: + continue + for run in runs: + if not _is_backup_run(run): + continue + under = run / prefix if prefix else run + files = [path for path in _backup_files(run) if under in path.parents] + if files: + found.append({"folder": run, "date": _backup_date(run), "files": len(files), + "bytes": sum(path.stat().st_size for path in files), + "source": "remove" if _backup_note(run) is not None + else "install"}) + return sorted(found, key=lambda item: item["folder"].name, reverse=True) + + +def run_backups(args: argparse.Namespace) -> int: + """--backups: the backups of the BIOS folder, newest first.""" + folder = _action_folder(args) + backups = list_backups(folder) + if not backups: + print(f"No backup of {shown_path(folder)}.") + return 0 + print(f"Backups of {shown_path(folder)}, newest first:") + for item in backups: + made = "removed" if item["source"] == "remove" else "replaced by an install" + print(f" {backup_words(item['date'])} {_plural(item['files'], 'file')}, " + f"{format_size(item['bytes'])}, {made}") + print(f" {shown_path(item['folder'])}") + EVENTS.emit("backup", folder=str(spelled(item["folder"])), date=item["date"], + files=item["files"], bytes=item["bytes"], source=item["source"], + status="listed", message="") + print(f"To put one back: {_INVOCATION.launcher()} --restore FOLDER") + return 0 + + +def run_delete_backup(args: argparse.Namespace) -> int: + """--delete-backup: one backup folder deleted after one confirmation. + 0 done, 1 a file failed, 2 refused or declined.""" + run = Path(os.path.abspath(os.path.expanduser(str(args.delete_backup)))) + if not _is_backup_run(run): + print(f"Error: {run} is not a folder the installer kept files in.", file=sys.stderr) + print(" Name one of the dated folders --backups lists.", file=sys.stderr) + return 2 + files = _backup_files(run) + size = sum(path.stat().st_size for path in files) + date = _backup_date(run) + source = "remove" if _backup_note(run) is not None else "install" + print(f"Delete the backup of {backup_words(date)}: {_plural(len(files), 'file')}, " + f"{format_size(size)}.") + if args.dry_run: + print("Nothing was deleted (--dry-run).") + return 0 + if not args.yes: + if args.no_input or not sys.stdin.isatty(): + print("Error: deleting a backup needs a yes: add --yes to delete it without " + "a question.", file=sys.stderr) + return 2 + if not _answer_yes("Delete it for good? [y/N] "): + print("Nothing was deleted.") + return QUIT_CODE + failed = 0 + # Bottom up, never through a link: a link goes, not what it names. + for base, dirs, names in os.walk(run, topdown=False, followlinks=False): + for name in [*names, *dirs]: + path = Path(base) / name + try: + if stat.S_ISDIR(os.lstat(path).st_mode): + path.rmdir() + else: + _clear_read_only(path) + path.unlink() + except OSError as exc: + failed += 1 + print(f" failed: {path} ({exc.strerror or exc})", file=sys.stderr) + try: + run.rmdir() + except OSError as exc: + failed += 1 + print(f" failed: {run} ({exc.strerror or exc})", file=sys.stderr) + EVENTS.emit("backup", folder=str(spelled(run)), date=date, files=len(files), bytes=size, + source=source, status="not_deleted" if failed else "deleted", + message=f"{_plural(failed, 'file')} could not be deleted." if failed else "") + if failed: + return 1 + print(f"Deleted, {format_size(size)} freed.") + return 0 + + +def _action_folder(args: argparse.Namespace) -> Path: + """The BIOS folder a listing of backups is for: --dest, else the one + setup found here.""" + if args.dest and not args.platform: + return Path(os.path.abspath(os.path.expanduser(str(args.dest)))) + return _action_setup(args)[1] + + +def _action_setup(args: argparse.Namespace) -> tuple[str, Path]: + """The setup and BIOS folder a removal is for: --platform and --dest, + the one setup a record names or the search here finds, never a guess. + A choice left open exits 2, naming the option that settles it.""" + named = args.platform or "" + plat = normalize_platform(args.platform, say=False) if args.platform else "" + if args.dest: + dest = Path(args.dest) + if plat: + plat, dest = card_dest(named, plat, dest) + folder = Path(os.path.abspath(os.path.expanduser(str(dest)))) + if plat: + return plat, folder + setups = sorted((load_record(folder).data or {}).get("setups") or {}) + if len(setups) == 1: + return setups[0], folder + print(f"Error: name the setup of {shown_path(folder)} with --platform" + + (f": {', '.join(setups)}." if setups else "."), file=sys.stderr) + sys.exit(2) + found = [(name, path) for name, path in detect_platforms(detect_os()) + if not plat or name == plat] + if len(found) == 1: + return found[0][0], Path(os.path.abspath(found[0][1])) + if not found: + print("Error: no setup found here: name its BIOS folder with --dest" + + ("." if plat else " and its setup with --platform."), file=sys.stderr) + else: + print("Error: several setups found here: name one with --platform and --dest.", + file=sys.stderr) + for name, path in found: + print(f" --platform {name} --dest {_INVOCATION.word(str(path))}", + file=sys.stderr) + sys.exit(2) def _download_one( @@ -14688,6 +15501,40 @@ def build_parser() -> argparse.ArgumentParser: help=f"put back the files an earlier run replaced and kept in FOLDER " f"(a dated folder inside {BACKUP_DIR})", ) + parser.add_argument( + "--remove", + action="store_true", + help="remove what the installer placed in the BIOS folder, as its install " + "record proves it: every file, or those of --system, --core, --emulator " + "or --file. A file changed since, added by hand, still needed by " + "something kept, or that the collection cannot give again stays", + ) + parser.add_argument( + "--file", + action="append", + type=Path, + metavar="PATH", + help="with --remove, one file of the BIOS folder (its path there, or the " + "full path). Repeat for several", + ) + parser.add_argument( + "--backup", + action="store_true", + help=f"with --remove, move the files into a dated folder of {BACKUP_DIR} " + "instead of deleting them, for --restore to put back", + ) + parser.add_argument( + "--backups", + action="store_true", + help="list the backups of the BIOS folder, newest first, and exit", + ) + parser.add_argument( + "--delete-backup", + type=Path, + metavar="FOLDER", + help=f"delete one backup (a dated folder inside {BACKUP_DIR}) after one " + "question, to free its space", + ) parser.add_argument( "--tui", action="store_true", @@ -14849,23 +15696,11 @@ def main() -> None: print(f"{parser.prog}: error: --tui cannot open the full-screen interface " f"here: {reason}", file=sys.stderr) sys.exit(2) + _check_actions(parser, args) if args.restore is not None: - # The folder says where each file goes: nothing else applies. - alone = ("platform", "dest", "target", "every_board", "check", "list_platforms", - "list_targets", "standalone_copies", "emulator", "installed_cores", - "cores_dir", "save_dir", "system", "core", "region", "no_game_data", - "declared_only", "list_systems", "required_only", - "list_cores", "romm_firmware", "list_emulators", "events", "quiet", - "yes", "verbose", "from_pack", "manifest", "from", "add_app") - given = [name for name in alone if getattr(args, name)] - if args.for_games is not None: - given.append("for_games") - if args.jobs != 8: - given.append("jobs") - if given: - parser.error("--restore puts files back from a folder and takes no " - f"--{given[0].replace('_', '-')}") - sys.exit(restore_kept(args.restore, args.dry_run)) + _run_action(args, lambda given: restore_kept(given.restore, given.dry_run)) + if args.delete_backup is not None: + _run_action(args, run_delete_backup) if args.every_board and args.target: parser.error("--every-board installs every board, and --target names one") if args.add_app: @@ -14901,6 +15736,9 @@ def main() -> None: parser.error(f"--target needs a core list, which {offline_named()} does not fetch: " "give --from-pack, or --manifest with targets/.json beside it") install_bounded_opener() + if args.remove or args.backups: + _open_local_sources(args) + _run_action(args, run_removal if args.remove else run_backups) if getattr(args, "from") and ( args.list_platforms or args.list_targets or args.list_systems or args.list_cores or args.list_emulators @@ -14947,6 +15785,72 @@ def main() -> None: _run(args) +# What each action reads beside its own option: anything else given with +# it is refused, never ignored. dest and platform name the BIOS folder. +_ACTION_TAKES = { + "restore": ("dry_run", "events", "yes", "no_input", "verbose"), + "delete_backup": ("dry_run", "events", "yes", "no_input", "verbose"), + "backups": ("platform", "dest", "events", "no_input", "verbose"), + "remove": ("platform", "dest", "system", "core", "emulator", "file", "backup", + "dry_run", "events", "yes", "no_input", "verbose", "manifest", "from_pack", + "offline"), +} +# Options that are a choice of their own, never refused for being given. +_ACTION_FREE = ("jobs", "plain", "from_record") + + +def _check_actions(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None: + """Refuse an action given with an option it does not read, two actions, + or --file and --backup without --remove.""" + actions = [name for name in _ACTION_TAKES if getattr(args, name)] + if len(actions) > 1: + parser.error(f"--{actions[0].replace('_', '-')} and --{actions[1].replace('_', '-')} " + "are two actions: run them one at a time") + if not actions: + for name in ("file", "backup"): + if getattr(args, name): + parser.error(f"--{name} applies to --remove") + return + action = actions[0] + defaults = build_parser().parse_args([]) + given = [name for name, value in vars(args).items() + if name != action and name not in _ACTION_TAKES[action] + and name not in _ACTION_FREE and value != getattr(defaults, name, value)] + if given: + parser.error(f"--{action.replace('_', '-')} takes no " + f"--{given[0].replace('_', '-')}") + if action == "remove": + narrowed = [flag for flag, value in (("--system", args.system), ("--core", args.core), + ("--emulator", args.emulator), + ("--file", args.file)) if value] + if len(narrowed) > 1: + parser.error(f"--remove takes one of --system, --core, --emulator or --file, " + f"not {narrowed[0]} and {narrowed[1]}") + + +def _run_action(args: argparse.Namespace, + action: Callable[[argparse.Namespace], int]) -> NoReturn: + """Run an action and exit with its code, its events framed by start and + end with --events.""" + if not args.events: + sys.exit(action(args)) + args.no_input = True + EVENTS.open(sys.stdout) + sys.stdout = sys.stderr + EVENTS.emit("start", schema=EVENT_SCHEMA, installer=installer_id(), + mode=_event_mode(args)) + try: + code = action(args) + except SystemExit as exc: + EVENTS.emit("end", code=_event_exit_code(exc.code)) + raise + except KeyboardInterrupt: + EVENTS.emit("end", code=130) + raise + EVENTS.emit("end", code=code) + sys.exit(code) + + def _run(args: argparse.Namespace) -> None: """The run, its text quiet or not, the archives it read closed after.""" try: @@ -18645,6 +19549,21 @@ class TuiState: # (platform, folder, board, applied, here) of the last board event: # kept across checks, a --target chosen since stops the event. self.board_seen: tuple | None = None + # More: what to remove (the --system of a removal, none for + # everything), whether into a backup, the plan its dry run read, + # the backups listed and why, the one chosen, the action confirmed + # (remove, restore, delete_backup), its events and its code. + self.removal_scope: list[tuple[str, str]] = [] + self.removal_backup: bool = False + self.removal: dict | None = None + self.backups: list[dict] | None = None + self.backups_for: str = "look" + self.backup_chosen: dict | None = None + self.action: str = "" + self.action_events: list[dict] = [] + self.action_code: int | None = None + # Whether the folder checked has a backup, read once each check. + self.has_backups: bool = False @property def screen(self) -> str: @@ -19132,6 +20051,8 @@ _TUI_MODES = { def tui_argv(state: TuiState, run: str, python: str, script: str) -> list[str]: """The child that does what the screen decided.""" + if run in TUI_ACTION_RUNS: + return tui_action_argv(state, run, python, script) # --dest=VALUE: a folder typed as -x would otherwise be read as an option. argv = [python, script, f"--platform={state.platform}", f"--dest={state.dest}"] if state.manifest_path is not None: @@ -19431,6 +20352,8 @@ def tui_systems(state: TuiState) -> list[str]: def tui_rows(state: TuiState) -> list[TuiRow]: """The rows of the current screen, in the order they are shown.""" screen = state.screen + if screen in _ACTION_SCREENS: + return _action_rows(state) if screen == "setup": # The focused one's folder is spelled out below the list. rows = [TuiRow("setup", label, value=index) @@ -19617,10 +20540,12 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: rows = [TuiRow("systems", TUI_SYSTEMS_ROW)] game_data = _game_data_row(state) return rows + ([game_data] if game_data is not None else []) + [ - *_details_row(state), TuiRow("options", "Change"), TuiRow("quit", "Quit")] + *_details_row(state), TuiRow("options", "Change"), *_more_row(state), + TuiRow("quit", "Quit")] if not (_needed(check) if check else 0) and not check.get("data_missing"): return [TuiRow("close", "Close"), *_games_rows(state), *_record_rows(state), - *_where_rows(state), *_details_row(state), TuiRow("options", "Change")] + *_where_rows(state), *_details_row(state), TuiRow("options", "Change"), + *_more_row(state)] # 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. @@ -19677,7 +20602,18 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: if state.unreadable: rows.append(TuiRow("unreadable", "Show the files that could not be read")) return rows + [*_where_rows(state), *_details_row(state), TuiRow("options", "Change"), - TuiRow("quit", "Quit")] + *_more_row(state), TuiRow("quit", "Quit")] + + +# The plan's row to remove files and to see or put back a backup. +TUI_MORE_ROW = TuiRow("more", "More") + + +def _more_row(state: TuiState) -> list[TuiRow]: + """More, where it has something to act on: an install record of this + setup in the folder, or a backup of it. A first install's plan keeps + its rows.""" + return [TUI_MORE_ROW] if state.record is not None or state.has_backups else [] # The plan's row for the record's choice, and the one back to the setups. @@ -20178,6 +21114,9 @@ def _replace(state: TuiState, screen: str) -> None: def _back(state: TuiState) -> list[tuple]: + if state.screen == "actdone" and not state.running: + # Done: what was planned is gone, and the plan is counted again. + return _activate_action(state, TuiRow("recheck", "")) effects = [("kill",)] if state.running else [] if state.running: # What the stopped run still reports belongs to no screen. @@ -20906,6 +21845,8 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: return _activate(state, only) state.editing = screen return [] + if screen in _ACTION_SCREENS: + return _activate_action(state, row) if screen == "setup": if action == "another": return _push(state, "platforms", _platform_focus(state, "retroarch")) @@ -21043,7 +21984,7 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: return _open_folder_screen(state, state.dest, "chosen", rebind=True) if action == "fromcard": return _open_from_browser(state) - if action in ("misplaced", "unreadable"): + if action in ("misplaced", "unreadable", "more"): return _push(state, action) if action == "recheck": return _start_check(state) @@ -22451,6 +23392,8 @@ def _space(state: TuiState, row: TuiRow) -> list[tuple]: def _take_event(state: TuiState, event: dict) -> None: + if _take_action_event(state, event): + return kind = event.get("event") if kind == "app" and state.run == "install": state.app = event @@ -22604,6 +23547,8 @@ def tui_receive(state: TuiState, message: tuple) -> list[tuple]: state.running = False state.tail = [line for line in tail if not line.startswith("RetroBIOS ")] tail = state.tail + if state.run in TUI_ACTION_RUNS: + return _action_ended(state, code, tail) if state.run == "check": if state.check is None: return _fail(state, tail, code) @@ -22621,6 +23566,7 @@ def tui_receive(state: TuiState, message: tuple) -> list[tuple]: for name in state.keep_ticked) state.keep_ticked = None state.facts = _tui_folder_facts(state) + state.has_backups = state.dest is not None and bool(list_backups(state.dest)) _replace(state, "plan") refocus, state.refocus = state.refocus, "" _focus_plan(state, refocus) @@ -22757,7 +23703,10 @@ _ROW_VERBS = { "recheck": "Check again", "gamedata": "Change", "share": "Choose", "morecores": "Show", "games": "Change", "startonly": "Change", "gameslist": "Show", "details": "Show", "device": "Open", "emulators": "Open", "emulator": "Tick", "board": "Choose", - "pack": "Choose", "offline": "Change", + "pack": "Choose", "offline": "Change", "more": "Open", "remove": "Open", + "backups": "Open", "putback": "Open", "dropbackup": "Open", "rmall": "Choose", + "rmsystem": "Choose", "rmgo": "Remove", "rmbackup": "Change", "backup": "Choose", + "bkrestore": "Put back", "bkdelete": "Delete", "actgo": "Confirm", } @@ -24748,6 +25697,8 @@ def details_lines(state: TuiState) -> list: under = state.screen lines = (_plan_text(state) if under == "plan" else _confirm_text(state) if under == "confirm" + else _removal_text(state) if under == "removal" + else tui_action_outcome(state) if under == "actdone" else tui_outcome(state)[1] if under == "result" else []) finally: state.stack.append(top) @@ -24767,6 +25718,343 @@ def _open_details(state: TuiState) -> list[tuple]: return _push(state, "details") +# The screens below More, each listing its rows in _action_rows. +_ACTION_SCREENS = ("more", "remove", "removal", "backups", "backup", "actconfirm", "actdone") +# The runs More starts, each a child as an install is: what --remove would +# take (removal_plan), the removal, the backups listed, one put back and +# one deleted. +TUI_ACTION_RUNS = ("removal_plan", "remove", "backups", "restore", "delete_backup") + + +def tui_action_argv(state: TuiState, run: str, python: str, script: str) -> list[str]: + """The child of a More action: its own options and none of the plan's, + whose --system is an install's choice, not what to remove.""" + if run in ("restore", "delete_backup"): + folder = str((state.backup_chosen or {}).get("folder", "")) + flag = "--restore" if run == "restore" else "--delete-backup" + return [python, script, f"{flag}={folder}", "--events", + *(["--yes"] if run == "delete_backup" else [])] + argv = [python, script, f"--platform={state.platform}", f"--dest={state.dest}"] + if run == "backups": + return argv + ["--backups", "--events"] + if state.manifest_path is not None: + argv.append(f"--manifest={state.manifest_path}") + argv += ["--remove", *(f"{flag}={value}" for flag, value in state.removal_scope)] + if state.removal_backup: + argv.append("--backup") + return argv + (["--dry-run", "--events"] if run == "removal_plan" else ["--events", "--yes"]) + + +def tui_removal_systems(state: TuiState) -> list[tuple[str, int, int]]: + """(system, files, bytes) of each system the install record holds files + of, by the list's systems, in the order Choose systems lists them.""" + if state.record is None or state.manifest is None: + return [] + served = _served(state.manifest, False) + held: dict[str, list[int]] = {} + for item in state.record.get("files") or []: + systems = served.get((str(item.get("root") or ""), str(item["dest"])), + (set(), [], None))[1] + for system in systems or [OTHER_SYSTEMS]: + tally = held.setdefault(system, [0, 0]) + tally[0] += 1 + tally[1] += int(item.get("size") or 0) + titles = {system: system_title(system, state.manifest) for system in held} + return [(system, held[system][0], held[system][1]) + for system in sorted(held, key=titles.__getitem__)] + + +def _more_rows(state: TuiState) -> list[TuiRow]: + return [TuiRow("remove", "Remove files"), TuiRow("backups", "Backups"), + TuiRow("putback", "Put back a backup"), TuiRow("dropbackup", "Delete a backup")] + + +def _remove_rows(state: TuiState) -> list[TuiRow]: + """Remove files: everything the record holds, then each system of it.""" + if state.record is None: + return [TuiRow("back", "Back")] + files = state.record.get("files") or [] + size = format_size(sum(int(item.get("size") or 0) for item in files)) + rows = [TuiRow("rmall", "All RetroBIOS files", size, keep_right=True)] + for system, count, length in tui_removal_systems(state): + rows.append(TuiRow("rmsystem", system_title(system, state.manifest), + format_size(length), system, narrow=(_plural(count, "file"),), + keep_right=True)) + return rows + + +def _removal_rows(state: TuiState) -> list[TuiRow]: + """The removal plan's rows: Remove with the space it frees, the backup + choice, Details and Back.""" + plan = state.removal + if state.running or plan is None: + return [] + rows = [] + if plan["files"]: + rows.append(TuiRow("rmgo", f"Move {format_size(plan['bytes'])} to a backup" + if state.removal_backup else f"Remove {format_size(plan['bytes'])}")) + rows.append(TuiRow("rmbackup", f"Keep a backup: {_yes(state.removal_backup)}", + "--backup")) + # B goes back: a Back row pushed what stays out of a 40x12 screen. + return rows + _details_row(state) + + +def _backup_rows(state: TuiState) -> list[TuiRow]: + if state.running or state.backups is None: + return [] + rows = [TuiRow("backup", backup_words(item["date"]), + f"{_plural(item['files'], 'file')}, {format_size(item['bytes'])}", index, + narrow=(format_size(item["bytes"]),), keep_right=True) + for index, item in enumerate(state.backups)] + return rows + [TuiRow("back", "Back")] + + +def _action_rows(state: TuiState) -> list[TuiRow]: + """The rows of the More screens, or None for another screen.""" + screen = state.screen + if screen == "more": + return _more_rows(state) + if screen == "remove": + return _remove_rows(state) + if screen == "removal": + return _removal_rows(state) + if screen == "backups": + return _backup_rows(state) + if screen == "backup": + return [TuiRow("bkrestore", "Put back"), TuiRow("bkdelete", "Delete"), + TuiRow("back", "Back")] + if screen == "actconfirm": + verb = {"remove": "Move to a backup" if state.removal_backup else "Remove", + "restore": "Put back", "delete_backup": "Delete"}[state.action] + return [TuiRow("actgo", verb), TuiRow("back", "Back")] + if screen == "actdone" and not state.running: + return [TuiRow("close", "Close"), TuiRow("recheck", "Check again")] + return [] + + +def _open_backups(state: TuiState, purpose: str) -> list[tuple]: + """The backups of the folder, read by a child: to look at them, or to + put one back or delete one.""" + state.backups_for, state.backups = purpose, None + _push(state, "backups") + return _start_run(state, "backups") + + +def _activate_action(state: TuiState, row: TuiRow) -> list[tuple]: + """A and Enter on the More screens.""" + screen, action = state.screen, row.action + if action == "back": + return _back(state) + if screen == "more" and action == "remove": + return _push(state, "remove") + if screen == "more": + return _open_backups(state, {"backups": "look", "putback": "restore", + "dropbackup": "delete_backup"}[action]) + if screen == "remove": + state.removal_scope = ([("--system", str(row.value))] if action == "rmsystem" + else []) + state.removal = None + _push(state, "removal") + return _start_run(state, "removal_plan") + if screen == "removal" and action == "rmbackup": + state.removal_backup = not state.removal_backup + return [] + if screen == "removal" and action == "rmgo": + state.action = "remove" + return _push(state, "actconfirm") + if screen == "backups" and action == "backup": + state.backup_chosen = (state.backups or [])[int(str(row.value))] + if state.backups_for == "look": + return _push(state, "backup") + state.action = state.backups_for + return _push(state, "actconfirm") + if screen == "backup": + state.action = "restore" if action == "bkrestore" else "delete_backup" + return _push(state, "actconfirm") + if screen == "actconfirm" and action == "actgo": + state.action_events, state.action_code = [], None + _replace(state, "actdone") + return _start_run(state, state.action) + if screen == "actdone" and action == "close": + state.exit_code = state.action_code if state.action_code is not None else 1 + return [] + if screen == "actdone" and action == "recheck": + # The record changed with the folder: read again, with its choice. + state.record_for = None + del state.stack[1:] + state.stack.append("checking") + return _start_check(state) + return [] + + +def _take_action_event(state: TuiState, event: dict) -> bool: + """An event of a More run, kept for its screen: whether it was one.""" + kind = event.get("event") + if kind == "removal_plan" and state.run in ("removal_plan", "remove"): + state.removal = event + elif kind == "backup" and state.run == "backups": + state.backups = [*(state.backups or []), event] + elif kind in ("removed", "restored", "backup") and state.run in ( + "remove", "restore", "delete_backup"): + state.action_events.append(event) + else: + return False + return True + + +def _action_ended(state: TuiState, code: int, tail: list[str]) -> list[tuple]: + """A More run's end: the plan, the list, or what the action did.""" + if state.run == "removal_plan": + if state.removal is None: + return _fail(state, tail, code) + return [] + if state.run == "backups": + if state.backups is None and code: + return _fail(state, tail, code) + state.backups = state.backups or [] + return [] + state.action_code = code if code >= 0 else 128 - code + return [] + + +def _removal_text(state: TuiState) -> list: + """The removal plan: what goes and the space freed, where, what stays + and why, the files themselves behind Details.""" + plan = state.removal or {} + folder = _short_path(state.dest, state.home) + if not plan.get("files"): + lines: list = ["Nothing here can be removed."] + elif state.removal_backup: + lines = [f"{_plural(plan['files'], 'file')} to a backup, " + f"{format_size(plan['bytes'])}."] + else: + lines = [f"{_plural(plan['files'], 'file')} go, {format_size(plan['bytes'])} freed."] + lines.append(_styled(f"{_FOLDER_LABEL}{folder}", "path")) + kept = plan.get("kept") or [] + if kept: + lines.append(f"{agree(len(kept), 'file stays', 'files stay')}: " + f"{_kept_words([{**item, 'what': ''} for item in kept])}.") + if not plan.get("files") and plan.get("message") and not kept: + lines.append(_detail(str(plan["message"]))) + lines += _details(["", "Removed:"] + [f" {dest}" for dest in plan.get("dests") or []] + if plan.get("dests") else []) + lines += _details(["", "Left as they are:"] + [ + f" {kept_line({**item, 'what': ''})}" for item in kept] if kept else []) + return lines + + +def _view_more(state: TuiState, width: int, height: int) -> tuple: + return ("More", _compose(state, width, height, [], tui_rows(state), + [], _status_footer(state)), + ("A/Enter Open", "B/Esc Back", "? Help")) + + +def _view_remove(state: TuiState, width: int, height: int) -> tuple: + if state.record is None: + before = ["No install record here: no file is known to be the installer's, so " + "nothing is removed."] + else: + before = ["Choose what to remove. A file changed or added by hand stays."] + return ("Remove files", _compose(state, width, height, before, tui_rows(state)), + ("A/Enter Choose", "B/Esc Back", "? Help")) + + +def _view_removal(state: TuiState, width: int, height: int) -> tuple: + if state.running or state.removal is None: + return ("Remove files", _compose(state, width, height, + ["Reading what can be removed..."], []), + ("B/Esc Back", "? Help")) + lines = _shown(state, _removal_text(state)) + lines = [piece for line in lines for piece in ( + [_styled(part, "folder" if index else "") for index, part in enumerate( + _wrap(str(line), width, " "))] + if getattr(line, "style", "") == "path" and cell_width(str(line)) > width + else [line])] + return ("Remove files", _compose(state, width, height, lines, tui_rows(state)), + ("A/Enter Choose", "B/Esc Back", "? Help")) + + +def _view_backups(state: TuiState, width: int, height: int) -> tuple: + if state.running or state.backups is None: + return ("Backups", _compose(state, width, height, ["Reading the backups..."], []), + ("B/Esc Back", "? Help")) + if not state.backups: + before = ["No backup of this folder."] + else: + before = [{"look": "Newest first.", "restore": "Choose the backup to put back.", + "delete_backup": "Choose the backup to delete."}[state.backups_for]] + return ("Backups", _compose(state, width, height, before, tui_rows(state)), + ("A/Enter Choose", "B/Esc Back", "? Help")) + + +def _view_backup(state: TuiState, width: int, height: int) -> tuple: + item = state.backup_chosen or {} + lines = [f"{backup_words(str(item.get('date', '')))}: " + f"{_plural(int(item.get('files', 0)), 'file')}, " + f"{format_size(int(item.get('bytes', 0)))}."] + return ("Backup", _compose(state, width, height, lines, tui_rows(state)), + ("A/Enter Choose", "B/Esc Back", "? Help")) + + +def _confirm_action_text(state: TuiState) -> list[str]: + if state.action == "remove": + plan = state.removal or {} + if state.removal_backup: + return [f"Move {_plural(plan.get('files', 0), 'file')} to a backup? The space " + "is freed once the backup is deleted."] + return [f"Remove {_plural(plan.get('files', 0), 'file')} for good? " + f"{format_size(plan.get('bytes', 0))} is freed."] + item = state.backup_chosen or {} + when = backup_words(str(item.get("date", ""))) + if state.action == "restore": + return [f"Put back the {_plural(int(item.get('files', 0)), 'file')} of {when}? " + "Each one replaces the file at its place."] + return [f"Delete the backup of {when} for good? " + f"{format_size(int(item.get('bytes', 0)))} is freed."] + + +def _view_actconfirm(state: TuiState, width: int, height: int) -> tuple: + return ("Confirm", _compose(state, width, height, _confirm_action_text(state), + tui_rows(state)), + ("A/Enter Choose", "B/Esc Back", "? Help")) + + +def tui_action_outcome(state: TuiState) -> list: + """What a More action did, in one line, then where.""" + events = state.action_events + done = [event for event in events if event.get("ok", True)] + failed = [event for event in events if not event.get("ok", True)] + size = format_size(sum(int(event.get("size") or 0) for event in done)) + if state.action == "remove" and state.removal_backup: + lines: list = [f"Moved {_plural(len(done), 'file')} to a backup, {size}."] + elif state.action == "remove": + lines = [f"Removed {_plural(len(done), 'file')}, {size} freed."] + elif state.action == "restore": + lines = [f"Put back {_plural(len(done), 'file')}, {size}."] + else: + gone = next((event for event in events if event.get("status") == "deleted"), None) + lines = ([f"Deleted the backup, {format_size(int(gone['bytes']))} freed."] + if gone is not None else ["The backup was not deleted."]) + if failed: + lines.append(_styled(f"{_plural(len(failed), 'file')} failed.", "failure")) + lines += _details([f" {event.get('dest') or event.get('path')}: {event['reason']}" + for event in failed]) + lines.append(_styled(f"{_FOLDER_LABEL}{_short_path(state.dest, state.home)}", "path")) + return lines + + +def _view_actdone(state: TuiState, width: int, height: int) -> tuple: + if state.running: + words = {"remove": "Removing the files...", "restore": "Putting the files back...", + "delete_backup": "Deleting the backup..."}[state.action] + return ("Working", _compose(state, width, height, [words], []), ("B/Esc Stop",)) + lines = _shown(state, tui_action_outcome(state)) + lines[0] = _styled(str(lines[0]), "title") + return ("Done" if state.action_code == 0 else "Not done", + _compose(state, width, height, lines, tui_rows(state)), + ("A/Enter Choose", "B/Esc Back", "? Help")) + + _TUI_VIEWS: dict[str, Callable[[TuiState, int, int], tuple]] = { "setup": _view_setup, "platforms": _view_platforms, "folder": _view_folder, "browse": _view_browse, "path": _view_path, "create": _view_create, @@ -24776,7 +26064,9 @@ _TUI_VIEWS: dict[str, Callable[[TuiState, int, int], tuple]] = { "installing": _view_installing, "result": _view_result, "error": _view_error, "quit": _view_quit, "cardroot": _view_cardroot, "misplaced": _view_misplaced, "unreadable": _view_unreadable, "games": _view_games, "details": _view_details, - "device": _view_device, "emulators": _view_emulators, + "device": _view_device, "emulators": _view_emulators, "more": _view_more, + "remove": _view_remove, "removal": _view_removal, "backups": _view_backups, + "backup": _view_backup, "actconfirm": _view_actconfirm, "actdone": _view_actdone, } diff --git a/install.sh b/install.sh index 747a6492d..fbcfeff31 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="67036bbe4db401de213d9a8ab44161b8b64bfde91834b438fff47802b5a461aa" +DEFAULT_INSTALL_SHA256="ce18b5e213fde35341231cd79a9c9205f57f3f096827dc50e26db9487d1b5349" # 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 4dc4c488d..cf4950b2f 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": 1220974, - "sha256": "67036bbe4db401de213d9a8ab44161b8b64bfde91834b438fff47802b5a461aa", + "size": 1280415, + "sha256": "ce18b5e213fde35341231cd79a9c9205f57f3f096827dc50e26db9487d1b5349", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1274599, - "sha256": "8ea3d79c9bbcec747555c3970edef0692db5a00ada0ff45892aaf68dcfc38eb6" + "size": 1334040, + "sha256": "5f526e2d068764c5a178eccaf82f97012ac8bda61a0db251501580a8efd698eb" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1220974, - "sha256": "67036bbe4db401de213d9a8ab44161b8b64bfde91834b438fff47802b5a461aa", + "size": 1280415, + "sha256": "ce18b5e213fde35341231cd79a9c9205f57f3f096827dc50e26db9487d1b5349", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1220974, - "sha256": "67036bbe4db401de213d9a8ab44161b8b64bfde91834b438fff47802b5a461aa", + "size": 1280415, + "sha256": "ce18b5e213fde35341231cd79a9c9205f57f3f096827dc50e26db9487d1b5349", "executable": false }, { diff --git a/schemas/install-events.schema.json b/schemas/install-events.schema.json index 0a6713ed3..3dd4d5f56 100644 --- a/schemas/install-events.schema.json +++ b/schemas/install-events.schema.json @@ -39,6 +39,10 @@ "summary", "app", "record", + "removal_plan", + "removed", + "restored", + "backup", "end" ] } @@ -131,6 +135,18 @@ { "$ref": "#/$defs/record" }, + { + "$ref": "#/$defs/removal_plan" + }, + { + "$ref": "#/$defs/removed" + }, + { + "$ref": "#/$defs/restored" + }, + { + "$ref": "#/$defs/backup" + }, { "$ref": "#/$defs/end" } @@ -160,7 +176,9 @@ "install", "check", "dry-run", - "list" + "list", + "remove", + "restore" ] } } @@ -1002,7 +1020,9 @@ "install", "check", "dry-run", - "list" + "list", + "remove", + "restore" ] }, "installed": { @@ -1127,7 +1147,11 @@ "applied", "written", "unreadable", - "not_written" + "not_written", + "listed", + "made", + "deleted", + "not_deleted" ] }, "choice": { @@ -1148,6 +1172,194 @@ } } }, + "removal_plan": { + "type": "object", + "additionalProperties": false, + "required": [ + "event", + "platform", + "folder", + "files", + "bytes", + "systems", + "dests", + "kept", + "gone", + "backup", + "message" + ], + "properties": { + "event": { + "const": "removal_plan" + }, + "platform": { + "type": "string" + }, + "folder": { + "type": "string" + }, + "files": { + "type": "integer", + "minimum": 0 + }, + "bytes": { + "type": "integer", + "minimum": 0 + }, + "systems": { + "type": "array", + "items": { + "type": "string" + } + }, + "dests": { + "type": "array", + "items": { + "type": "string" + } + }, + "kept": { + "type": "array", + "items": { + "type": "object" + } + }, + "gone": { + "type": "integer", + "minimum": 0 + }, + "backup": { + "type": "boolean" + }, + "message": { + "type": "string" + } + } + }, + "removed": { + "type": "object", + "additionalProperties": false, + "required": [ + "event", + "platform", + "dest", + "size", + "ok", + "reason", + "moved_to" + ], + "properties": { + "event": { + "const": "removed" + }, + "platform": { + "type": "string" + }, + "dest": { + "type": "string" + }, + "size": { + "type": "integer", + "minimum": 0 + }, + "ok": { + "type": "boolean" + }, + "reason": { + "type": "string" + }, + "moved_to": { + "type": "string" + } + } + }, + "restored": { + "type": "object", + "additionalProperties": false, + "required": [ + "event", + "folder", + "path", + "size", + "ok", + "reason" + ], + "properties": { + "event": { + "const": "restored" + }, + "folder": { + "type": "string" + }, + "path": { + "type": "string" + }, + "size": { + "type": "integer", + "minimum": 0 + }, + "ok": { + "type": "boolean" + }, + "reason": { + "type": "string" + } + } + }, + "backup": { + "type": "object", + "additionalProperties": false, + "required": [ + "event", + "folder", + "date", + "files", + "bytes", + "source", + "status", + "message" + ], + "properties": { + "event": { + "const": "backup" + }, + "folder": { + "type": "string" + }, + "date": { + "type": "string" + }, + "files": { + "type": "integer", + "minimum": 0 + }, + "bytes": { + "type": "integer", + "minimum": 0 + }, + "source": { + "enum": [ + "remove", + "install" + ] + }, + "status": { + "enum": [ + "applied", + "written", + "unreadable", + "not_written", + "listed", + "made", + "deleted", + "not_deleted" + ] + }, + "message": { + "type": "string" + } + } + }, "end": { "type": "object", "additionalProperties": false, diff --git a/scripts/generate_event_schema.py b/scripts/generate_event_schema.py index 0242aae8d..f10f73021 100644 --- a/scripts/generate_event_schema.py +++ b/scripts/generate_event_schema.py @@ -37,18 +37,21 @@ 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", "date", + "list_version", "installed_version", "board", "core", "date", "moved_to", ), _TEXT), - "mode": {"enum": ["install", "check", "dry-run", "list"]}, + "mode": {"enum": ["install", "check", "dry-run", "list", "remove", "restore"]}, + "source": {"enum": ["remove", "install"]}, "state": {"enum": ["present", "wrong_hash", "missing"]}, - "status": {"enum": ["applied", "written", "unreadable", "not_written"]}, + "status": {"enum": ["applied", "written", "unreadable", "not_written", "listed", "made", + "deleted", "not_deleted"]}, **dict.fromkeys(( "files", "files_done", "files_total", "bytes", "bytes_done", "bytes_total", "bytes_local", "bytes_network", "size", "present", "missing", "wrong_hash", "bytes_needed", "data_missing", "not_installable", "installed", "up_to_date", - "pending", "code", "games", "recognised", "not_games", + "pending", "code", "games", "recognised", "not_games", "gone", ), _COUNT), - **dict.fromkeys(("truncated", "ok", "interrupted", "required_only", "applied", "info"), _FLAG), + **dict.fromkeys(("truncated", "ok", "interrupted", "required_only", "applied", "info", + "backup"), _FLAG), **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/backups-120x40.txt b/tests/fixtures/tui/backups-120x40.txt new file mode 100644 index 000000000..c126ae74f --- /dev/null +++ b/tests/fixtures/tui/backups-120x40.txt @@ -0,0 +1,40 @@ + RetroBIOS: Backups + + Newest first. + + > 3 Nov 12:00 2 files, 512.0 KB + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + A/Enter Choose B/Esc Back ? Help diff --git a/tests/fixtures/tui/backups-40x12.txt b/tests/fixtures/tui/backups-40x12.txt new file mode 100644 index 000000000..6f86c2f22 --- /dev/null +++ b/tests/fixtures/tui/backups-40x12.txt @@ -0,0 +1,12 @@ + RetroBIOS: Backups + + Newest first. + + > 3 Nov 12:00 2 files, 512.0 KB + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + A Choose B Back ? Help diff --git a/tests/fixtures/tui/backups-40x15.txt b/tests/fixtures/tui/backups-40x15.txt new file mode 100644 index 000000000..c3f630d40 --- /dev/null +++ b/tests/fixtures/tui/backups-40x15.txt @@ -0,0 +1,15 @@ + RetroBIOS: Backups + + Newest first. + + > 3 Nov 12:00 2 files, 512.0 KB + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + + + + A Choose B Back ? Help diff --git a/tests/fixtures/tui/backups-80x24.txt b/tests/fixtures/tui/backups-80x24.txt new file mode 100644 index 000000000..baaf2ef0f --- /dev/null +++ b/tests/fixtures/tui/backups-80x24.txt @@ -0,0 +1,24 @@ + RetroBIOS: Backups + + Newest first. + + > 3 Nov 12:00 2 files, 512.0 KB + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + + + + + + + + + + + + + A/Enter Choose B/Esc Back ? Help diff --git a/tests/fixtures/tui/colour-120x40.txt b/tests/fixtures/tui/colour-120x40.txt index 769b1cf0a..bd2fed376 100644 --- a/tests/fixtures/tui/colour-120x40.txt +++ b/tests/fixtures/tui/colour-120x40.txt @@ -1160,6 +1160,7 @@ Change where Details Change + More Quit @@ -1183,7 +1184,252 @@ - ^[[0;1;35mA/Enter^[[0m Install ^[[0;2mB/Esc^[[0m Back ^[[0;2mY/o^[[0m Change ^[[0;2m?^[[0m Help +=== more +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: More^[[0m + +^[[0;7;35m > Remove files^[[0m + Backups + Put back a backup + Delete a backup + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ^[[0;1;35mA/Enter^[[0m Open ^[[0;2mB/Esc^[[0m Back ^[[0;2m?^[[0m Help +=== remove +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Remove files^[[0m + + Choose what to remove. A file changed or added by hand stays. + +^[[0;7;35m > All RetroBIOS files 528.0 KB^[[0m + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ^[[0;1;35mA/Enter^[[0m Choose ^[[0;2mB/Esc^[[0m Back ^[[0;2m?^[[0m Help +=== removal +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Remove files^[[0m + + 2 files go, 512.0 KB freed. + BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m + 1 file stays: 1 changed since it was installed. + +^[[0;7;35m > Remove 512.0 KB^[[0m + Keep a backup: no --backup + Details + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ^[[0;1;35mA/Enter^[[0m Remove ^[[0;2mB/Esc^[[0m Back ^[[0;2m?^[[0m Help +=== removal-confirm +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Confirm^[[0m + + Remove 2 files for good? 512.0 KB is freed. + +^[[0;7;35m > Remove^[[0m + Back + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ^[[0;1;35mA/Enter^[[0m Confirm ^[[0;2mB/Esc^[[0m Back ^[[0;2m?^[[0m Help +=== removal-done +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;32mDone^[[0m + +^[[0;1m Removed ^[[0;1;35m2^[[0;1m files, ^[[0;1;35m512.0 KB^[[0;1m freed.^[[0m + BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m + +^[[0;7;35m > Close^[[0m + Check again + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ^[[0;1;35mA/Enter^[[0m Close ^[[0;2mB/Esc^[[0m Back ^[[0;2m?^[[0m Help +=== backups +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Backups^[[0m + + Newest first. + +^[[0;7;35m > 3 Nov 12:00 2 files, 512.0 KB^[[0m + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ^[[0;1;35mA/Enter^[[0m Choose ^[[0;2mB/Esc^[[0m Back ^[[0;2m?^[[0m Help diff --git a/tests/fixtures/tui/colour-40x12.txt b/tests/fixtures/tui/colour-40x12.txt index 7feccc9e3..ca8b09433 100644 --- a/tests/fixtures/tui/colour-40x12.txt +++ b/tests/fixtures/tui/colour-40x12.txt @@ -373,5 +373,83 @@ 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;2m 1-4 of 9^[[0m ^[[0;1;35mA^[[0m Install ^[[0;2mB^[[0m Back ^[[0;2mY^[[0m Change ^[[0;2m?^[[0m Help +=== more +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: More^[[0m + +^[[0;7;35m > Remove files^[[0m + Backups + Put back a backup + Delete a backup + + + + + + ^[[0;1;35mA^[[0m Open ^[[0;2mB^[[0m Back ^[[0;2m?^[[0m Help +=== remove +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Remove files^[[0m + + Choose what to remove. A file changed + or added by hand stays. + +^[[0;7;35m > All RetroBIOS files 528.0 KB^[[0m + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + ^[[0;1;35mA^[[0m Choose ^[[0;2mB^[[0m Back ^[[0;2m?^[[0m Help +=== removal +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Remove files^[[0m + + 2 files go, 512.0 KB freed. + BIOS folder: + ^[[0;1m~/.config/retroarch/system^[[0m + 1 file stays: 1 changed since it was + installed. + +^[[0;7;35m > Remove 512.0 KB^[[0m + Keep a backup: no --backup + Details + ^[[0;1;35mA^[[0m Remove ^[[0;2mB^[[0m Back ^[[0;2m?^[[0m Help +=== removal-confirm +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Confirm^[[0m + + Remove 2 files for good? 512.0 KB is + freed. + +^[[0;7;35m > Remove^[[0m + Back + + + + + ^[[0;1;35mA^[[0m Confirm ^[[0;2mB^[[0m Back ^[[0;2m?^[[0m Help +=== removal-done +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;32mDone^[[0m + +^[[0;1m Removed ^[[0;1;35m2^[[0;1m files, ^[[0;1;35m512.0 KB^[[0;1m freed.^[[0m + BIOS folder: ^[[0;1m~/.co.../retroarch/system^[[0m + +^[[0;7;35m > Close^[[0m + Check again + + + + + ^[[0;1;35mA^[[0m Close ^[[0;2mB^[[0m Back ^[[0;2m?^[[0m Help +=== backups +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Backups^[[0m + + Newest first. + +^[[0;7;35m > 3 Nov 12:00 2 files, 512.0 KB^[[0m + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + ^[[0;1;35mA^[[0m Choose ^[[0;2mB^[[0m Back ^[[0;2m?^[[0m Help diff --git a/tests/fixtures/tui/colour-40x15.txt b/tests/fixtures/tui/colour-40x15.txt index 6142be474..f04989f6e 100644 --- a/tests/fixtures/tui/colour-40x15.txt +++ b/tests/fixtures/tui/colour-40x15.txt @@ -452,7 +452,6 @@ ^[[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 @@ -460,5 +459,102 @@ Change where Details Change + More Quit ^[[0;1;35mA^[[0m Install ^[[0;2mB^[[0m Back ^[[0;2mY^[[0m Change ^[[0;2m?^[[0m Help +=== more +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: More^[[0m + +^[[0;7;35m > Remove files^[[0m + Backups + Put back a backup + Delete a backup + + + + + + + + + ^[[0;1;35mA^[[0m Open ^[[0;2mB^[[0m Back ^[[0;2m?^[[0m Help +=== remove +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Remove files^[[0m + + Choose what to remove. A file changed + or added by hand stays. + +^[[0;7;35m > All RetroBIOS files 528.0 KB^[[0m + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + + + + ^[[0;1;35mA^[[0m Choose ^[[0;2mB^[[0m Back ^[[0;2m?^[[0m Help +=== removal +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Remove files^[[0m + + 2 files go, 512.0 KB freed. + BIOS folder: + ^[[0;1m~/.config/retroarch/system^[[0m + 1 file stays: 1 changed since it was + installed. + +^[[0;7;35m > Remove 512.0 KB^[[0m + Keep a backup: no --backup + Details + + + + ^[[0;1;35mA^[[0m Remove ^[[0;2mB^[[0m Back ^[[0;2m?^[[0m Help +=== removal-confirm +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Confirm^[[0m + + Remove 2 files for good? 512.0 KB is + freed. + +^[[0;7;35m > Remove^[[0m + Back + + + + + + + + ^[[0;1;35mA^[[0m Confirm ^[[0;2mB^[[0m Back ^[[0;2m?^[[0m Help +=== removal-done +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;32mDone^[[0m + +^[[0;1m Removed ^[[0;1;35m2^[[0;1m files, ^[[0;1;35m512.0 KB^[[0;1m freed.^[[0m + BIOS folder: ^[[0;1m~/.co.../retroarch/system^[[0m + +^[[0;7;35m > Close^[[0m + Check again + + + + + + + + ^[[0;1;35mA^[[0m Close ^[[0;2mB^[[0m Back ^[[0;2m?^[[0m Help +=== backups +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Backups^[[0m + + Newest first. + +^[[0;7;35m > 3 Nov 12:00 2 files, 512.0 KB^[[0m + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + + + + ^[[0;1;35mA^[[0m Choose ^[[0;2mB^[[0m Back ^[[0;2m?^[[0m Help diff --git a/tests/fixtures/tui/colour-80x24.txt b/tests/fixtures/tui/colour-80x24.txt index 3afb4a9c1..cc8570b48 100644 --- a/tests/fixtures/tui/colour-80x24.txt +++ b/tests/fixtures/tui/colour-80x24.txt @@ -712,6 +712,7 @@ Change where Details Change + More Quit @@ -721,5 +722,154 @@ - ^[[0;1;35mA/Enter^[[0m Install ^[[0;2mB/Esc^[[0m Back ^[[0;2mY/o^[[0m Change ^[[0;2m?^[[0m Help +=== more +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: More^[[0m + +^[[0;7;35m > Remove files^[[0m + Backups + Put back a backup + Delete a backup + + + + + + + + + + + + + + + + + + ^[[0;1;35mA/Enter^[[0m Open ^[[0;2mB/Esc^[[0m Back ^[[0;2m?^[[0m Help +=== remove +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Remove files^[[0m + + Choose what to remove. A file changed or added by hand stays. + +^[[0;7;35m > All RetroBIOS files 528.0 KB^[[0m + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + + + + + + + + + + + + + + ^[[0;1;35mA/Enter^[[0m Choose ^[[0;2mB/Esc^[[0m Back ^[[0;2m?^[[0m Help +=== removal +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Remove files^[[0m + + 2 files go, 512.0 KB freed. + BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m + 1 file stays: 1 changed since it was installed. + +^[[0;7;35m > Remove 512.0 KB^[[0m + Keep a backup: no --backup + Details + + + + + + + + + + + + + + + ^[[0;1;35mA/Enter^[[0m Remove ^[[0;2mB/Esc^[[0m Back ^[[0;2m?^[[0m Help +=== removal-confirm +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Confirm^[[0m + + Remove 2 files for good? 512.0 KB is freed. + +^[[0;7;35m > Remove^[[0m + Back + + + + + + + + + + + + + + + + + + ^[[0;1;35mA/Enter^[[0m Confirm ^[[0;2mB/Esc^[[0m Back ^[[0;2m?^[[0m Help +=== removal-done +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;32mDone^[[0m + +^[[0;1m Removed ^[[0;1;35m2^[[0;1m files, ^[[0;1;35m512.0 KB^[[0;1m freed.^[[0m + BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m + +^[[0;7;35m > Close^[[0m + Check again + + + + + + + + + + + + + + + + + ^[[0;1;35mA/Enter^[[0m Close ^[[0;2mB/Esc^[[0m Back ^[[0;2m?^[[0m Help +=== backups +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Backups^[[0m + + Newest first. + +^[[0;7;35m > 3 Nov 12:00 2 files, 512.0 KB^[[0m + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + + + + + + + + + + + + + ^[[0;1;35mA/Enter^[[0m Choose ^[[0;2mB/Esc^[[0m Back ^[[0;2m?^[[0m Help diff --git a/tests/fixtures/tui/more-120x40.txt b/tests/fixtures/tui/more-120x40.txt new file mode 100644 index 000000000..60e3ad3b9 --- /dev/null +++ b/tests/fixtures/tui/more-120x40.txt @@ -0,0 +1,40 @@ + RetroBIOS: More + + > Remove files + Backups + Put back a backup + Delete a backup + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + A/Enter Open B/Esc Back ? Help diff --git a/tests/fixtures/tui/more-40x12.txt b/tests/fixtures/tui/more-40x12.txt new file mode 100644 index 000000000..ae70101fa --- /dev/null +++ b/tests/fixtures/tui/more-40x12.txt @@ -0,0 +1,12 @@ + RetroBIOS: More + + > Remove files + Backups + Put back a backup + Delete a backup + + + + + + A Open B Back ? Help diff --git a/tests/fixtures/tui/more-40x15.txt b/tests/fixtures/tui/more-40x15.txt new file mode 100644 index 000000000..f6d6a6794 --- /dev/null +++ b/tests/fixtures/tui/more-40x15.txt @@ -0,0 +1,15 @@ + RetroBIOS: More + + > Remove files + Backups + Put back a backup + Delete a backup + + + + + + + + + A Open B Back ? Help diff --git a/tests/fixtures/tui/more-80x24.txt b/tests/fixtures/tui/more-80x24.txt new file mode 100644 index 000000000..2d1d511d9 --- /dev/null +++ b/tests/fixtures/tui/more-80x24.txt @@ -0,0 +1,24 @@ + RetroBIOS: More + + > Remove files + Backups + Put back a backup + Delete a backup + + + + + + + + + + + + + + + + + + A/Enter Open B/Esc Back ? Help diff --git a/tests/fixtures/tui/plan-record-120x40.txt b/tests/fixtures/tui/plan-record-120x40.txt index 4a761b638..dd16da484 100644 --- a/tests/fixtures/tui/plan-record-120x40.txt +++ b/tests/fixtures/tui/plan-record-120x40.txt @@ -11,6 +11,7 @@ Change where Details Change + More Quit @@ -34,7 +35,6 @@ - A/Enter Install B/Esc Back Y/o Change ? Help diff --git a/tests/fixtures/tui/plan-record-40x12.txt b/tests/fixtures/tui/plan-record-40x12.txt index ef764f841..627748831 100644 --- a/tests/fixtures/tui/plan-record-40x12.txt +++ b/tests/fixtures/tui/plan-record-40x12.txt @@ -8,5 +8,5 @@ Same choice as on 3 Nov: yes Choose systems Copy from an old card or a folder - 1-4 of 8 + 1-4 of 9 A Install B Back Y Change ? Help diff --git a/tests/fixtures/tui/plan-record-40x15.txt b/tests/fixtures/tui/plan-record-40x15.txt index 778f75173..1e1e2c35a 100644 --- a/tests/fixtures/tui/plan-record-40x15.txt +++ b/tests/fixtures/tui/plan-record-40x15.txt @@ -3,7 +3,6 @@ 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 @@ -11,5 +10,6 @@ Change where Details Change + More Quit A Install B Back Y Change ? Help diff --git a/tests/fixtures/tui/plan-record-80x24.txt b/tests/fixtures/tui/plan-record-80x24.txt index b32d9a48a..3d28dcad2 100644 --- a/tests/fixtures/tui/plan-record-80x24.txt +++ b/tests/fixtures/tui/plan-record-80x24.txt @@ -11,6 +11,7 @@ Change where Details Change + More Quit @@ -20,5 +21,4 @@ - A/Enter Install B/Esc Back Y/o Change ? Help diff --git a/tests/fixtures/tui/removal-120x40.txt b/tests/fixtures/tui/removal-120x40.txt new file mode 100644 index 000000000..c3b457657 --- /dev/null +++ b/tests/fixtures/tui/removal-120x40.txt @@ -0,0 +1,40 @@ + RetroBIOS: Remove files + + 2 files go, 512.0 KB freed. + BIOS folder: ~/.config/retroarch/system + 1 file stays: 1 changed since it was installed. + + > Remove 512.0 KB + Keep a backup: no --backup + Details + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + A/Enter Remove B/Esc Back ? Help diff --git a/tests/fixtures/tui/removal-40x12.txt b/tests/fixtures/tui/removal-40x12.txt new file mode 100644 index 000000000..e8c16edaa --- /dev/null +++ b/tests/fixtures/tui/removal-40x12.txt @@ -0,0 +1,12 @@ + RetroBIOS: Remove files + + 2 files go, 512.0 KB freed. + BIOS folder: + ~/.config/retroarch/system + 1 file stays: 1 changed since it was + installed. + + > Remove 512.0 KB + Keep a backup: no --backup + Details + A Remove B Back ? Help diff --git a/tests/fixtures/tui/removal-40x15.txt b/tests/fixtures/tui/removal-40x15.txt new file mode 100644 index 000000000..fabea9e6e --- /dev/null +++ b/tests/fixtures/tui/removal-40x15.txt @@ -0,0 +1,15 @@ + RetroBIOS: Remove files + + 2 files go, 512.0 KB freed. + BIOS folder: + ~/.config/retroarch/system + 1 file stays: 1 changed since it was + installed. + + > Remove 512.0 KB + Keep a backup: no --backup + Details + + + + A Remove B Back ? Help diff --git a/tests/fixtures/tui/removal-80x24.txt b/tests/fixtures/tui/removal-80x24.txt new file mode 100644 index 000000000..f19e72819 --- /dev/null +++ b/tests/fixtures/tui/removal-80x24.txt @@ -0,0 +1,24 @@ + RetroBIOS: Remove files + + 2 files go, 512.0 KB freed. + BIOS folder: ~/.config/retroarch/system + 1 file stays: 1 changed since it was installed. + + > Remove 512.0 KB + Keep a backup: no --backup + Details + + + + + + + + + + + + + + + A/Enter Remove B/Esc Back ? Help diff --git a/tests/fixtures/tui/removal-confirm-120x40.txt b/tests/fixtures/tui/removal-confirm-120x40.txt new file mode 100644 index 000000000..9cf5ae26b --- /dev/null +++ b/tests/fixtures/tui/removal-confirm-120x40.txt @@ -0,0 +1,40 @@ + RetroBIOS: Confirm + + Remove 2 files for good? 512.0 KB is freed. + + > Remove + Back + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + A/Enter Confirm B/Esc Back ? Help diff --git a/tests/fixtures/tui/removal-confirm-40x12.txt b/tests/fixtures/tui/removal-confirm-40x12.txt new file mode 100644 index 000000000..ae2a5d215 --- /dev/null +++ b/tests/fixtures/tui/removal-confirm-40x12.txt @@ -0,0 +1,12 @@ + RetroBIOS: Confirm + + Remove 2 files for good? 512.0 KB is + freed. + + > Remove + Back + + + + + A Confirm B Back ? Help diff --git a/tests/fixtures/tui/removal-confirm-40x15.txt b/tests/fixtures/tui/removal-confirm-40x15.txt new file mode 100644 index 000000000..d5eeca371 --- /dev/null +++ b/tests/fixtures/tui/removal-confirm-40x15.txt @@ -0,0 +1,15 @@ + RetroBIOS: Confirm + + Remove 2 files for good? 512.0 KB is + freed. + + > Remove + Back + + + + + + + + A Confirm B Back ? Help diff --git a/tests/fixtures/tui/removal-confirm-80x24.txt b/tests/fixtures/tui/removal-confirm-80x24.txt new file mode 100644 index 000000000..d1ebf955d --- /dev/null +++ b/tests/fixtures/tui/removal-confirm-80x24.txt @@ -0,0 +1,24 @@ + RetroBIOS: Confirm + + Remove 2 files for good? 512.0 KB is freed. + + > Remove + Back + + + + + + + + + + + + + + + + + + A/Enter Confirm B/Esc Back ? Help diff --git a/tests/fixtures/tui/removal-done-120x40.txt b/tests/fixtures/tui/removal-done-120x40.txt new file mode 100644 index 000000000..fd22fef58 --- /dev/null +++ b/tests/fixtures/tui/removal-done-120x40.txt @@ -0,0 +1,40 @@ + RetroBIOS: Done + + Removed 2 files, 512.0 KB freed. + BIOS folder: ~/.config/retroarch/system + + > Close + Check again + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + A/Enter Close B/Esc Back ? Help diff --git a/tests/fixtures/tui/removal-done-40x12.txt b/tests/fixtures/tui/removal-done-40x12.txt new file mode 100644 index 000000000..2c7371ba5 --- /dev/null +++ b/tests/fixtures/tui/removal-done-40x12.txt @@ -0,0 +1,12 @@ + RetroBIOS: Done + + Removed 2 files, 512.0 KB freed. + BIOS folder: ~/.co.../retroarch/system + + > Close + Check again + + + + + A Close B Back ? Help diff --git a/tests/fixtures/tui/removal-done-40x15.txt b/tests/fixtures/tui/removal-done-40x15.txt new file mode 100644 index 000000000..3c2e12986 --- /dev/null +++ b/tests/fixtures/tui/removal-done-40x15.txt @@ -0,0 +1,15 @@ + RetroBIOS: Done + + Removed 2 files, 512.0 KB freed. + BIOS folder: ~/.co.../retroarch/system + + > Close + Check again + + + + + + + + A Close B Back ? Help diff --git a/tests/fixtures/tui/removal-done-80x24.txt b/tests/fixtures/tui/removal-done-80x24.txt new file mode 100644 index 000000000..16aedc05b --- /dev/null +++ b/tests/fixtures/tui/removal-done-80x24.txt @@ -0,0 +1,24 @@ + RetroBIOS: Done + + Removed 2 files, 512.0 KB freed. + BIOS folder: ~/.config/retroarch/system + + > Close + Check again + + + + + + + + + + + + + + + + + A/Enter Close B/Esc Back ? Help diff --git a/tests/fixtures/tui/remove-120x40.txt b/tests/fixtures/tui/remove-120x40.txt new file mode 100644 index 000000000..810396d56 --- /dev/null +++ b/tests/fixtures/tui/remove-120x40.txt @@ -0,0 +1,40 @@ + RetroBIOS: Remove files + + Choose what to remove. A file changed or added by hand stays. + + > All RetroBIOS files 528.0 KB + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + A/Enter Choose B/Esc Back ? Help diff --git a/tests/fixtures/tui/remove-40x12.txt b/tests/fixtures/tui/remove-40x12.txt new file mode 100644 index 000000000..526499d8b --- /dev/null +++ b/tests/fixtures/tui/remove-40x12.txt @@ -0,0 +1,12 @@ + RetroBIOS: Remove files + + Choose what to remove. A file changed + or added by hand stays. + + > All RetroBIOS files 528.0 KB + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + A Choose B Back ? Help diff --git a/tests/fixtures/tui/remove-40x15.txt b/tests/fixtures/tui/remove-40x15.txt new file mode 100644 index 000000000..4a0bdb648 --- /dev/null +++ b/tests/fixtures/tui/remove-40x15.txt @@ -0,0 +1,15 @@ + RetroBIOS: Remove files + + Choose what to remove. A file changed + or added by hand stays. + + > All RetroBIOS files 528.0 KB + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + + + + A Choose B Back ? Help diff --git a/tests/fixtures/tui/remove-80x24.txt b/tests/fixtures/tui/remove-80x24.txt new file mode 100644 index 000000000..c7eb07835 --- /dev/null +++ b/tests/fixtures/tui/remove-80x24.txt @@ -0,0 +1,24 @@ + RetroBIOS: Remove files + + Choose what to remove. A file changed or added by hand stays. + + > All RetroBIOS files 528.0 KB + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + + + + + + + + + + + + + + A/Enter Choose B/Esc Back ? Help diff --git a/tests/fixtures/tui/route-evmapy.txt b/tests/fixtures/tui/route-evmapy.txt index 087fda8c0..bf31d3862 100644 --- a/tests/fixtures/tui/route-evmapy.txt +++ b/tests/fixtures/tui/route-evmapy.txt @@ -1302,7 +1302,7 @@ Same choice as on 3 Nov: yes Choose systems Copy from an old card or a folder - 1-4 of 8 + 1-4 of 9 A Install B Back Y Change === plan-record 40x15 RetroBIOS: RetroArch @@ -1310,7 +1310,6 @@ 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 @@ -1318,6 +1317,7 @@ Change where Details Change + More Quit A Install B Back Y Change === plan-record 80x24 @@ -1334,6 +1334,7 @@ Change where Details Change + More Quit @@ -1343,5 +1344,328 @@ - A Install B Back Y Change +=== more 40x12 + RetroBIOS: More + + > Remove files + Backups + Put back a backup + Delete a backup + + + + + + A Open B Back +=== more 40x15 + RetroBIOS: More + + > Remove files + Backups + Put back a backup + Delete a backup + + + + + + + + + A Open B Back +=== more 80x24 + RetroBIOS: More + + > Remove files + Backups + Put back a backup + Delete a backup + + + + + + + + + + + + + + + + + + A Open B Back +=== remove 40x12 + RetroBIOS: Remove files + + Choose what to remove. A file changed + or added by hand stays. + + > All RetroBIOS files 528.0 KB + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + A Choose B Back +=== remove 40x15 + RetroBIOS: Remove files + + Choose what to remove. A file changed + or added by hand stays. + + > All RetroBIOS files 528.0 KB + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + + + + A Choose B Back +=== remove 80x24 + RetroBIOS: Remove files + + Choose what to remove. A file changed or added by hand stays. + + > All RetroBIOS files 528.0 KB + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + + + + + + + + + + + + + + A Choose B Back +=== removal 40x12 + RetroBIOS: Remove files + + 2 files go, 512.0 KB freed. + BIOS folder: + ~/.config/retroarch/system + 1 file stays: 1 changed since it was + installed. + + > Remove 512.0 KB + Keep a backup: no --backup + Details + A Remove B Back +=== removal 40x15 + RetroBIOS: Remove files + + 2 files go, 512.0 KB freed. + BIOS folder: + ~/.config/retroarch/system + 1 file stays: 1 changed since it was + installed. + + > Remove 512.0 KB + Keep a backup: no --backup + Details + + + + A Remove B Back +=== removal 80x24 + RetroBIOS: Remove files + + 2 files go, 512.0 KB freed. + BIOS folder: ~/.config/retroarch/system + 1 file stays: 1 changed since it was installed. + + > Remove 512.0 KB + Keep a backup: no --backup + Details + + + + + + + + + + + + + + + A Remove B Back +=== removal-confirm 40x12 + RetroBIOS: Confirm + + Remove 2 files for good? 512.0 KB is + freed. + + > Remove + Back + + + + + A Confirm B Back +=== removal-confirm 40x15 + RetroBIOS: Confirm + + Remove 2 files for good? 512.0 KB is + freed. + + > Remove + Back + + + + + + + + A Confirm B Back +=== removal-confirm 80x24 + RetroBIOS: Confirm + + Remove 2 files for good? 512.0 KB is freed. + + > Remove + Back + + + + + + + + + + + + + + + + + + A Confirm B Back +=== removal-done 40x12 + RetroBIOS: Done + + Removed 2 files, 512.0 KB freed. + BIOS folder: ~/.co.../retroarch/system + + > Close + Check again + + + + + A Close B Back +=== removal-done 40x15 + RetroBIOS: Done + + Removed 2 files, 512.0 KB freed. + BIOS folder: ~/.co.../retroarch/system + + > Close + Check again + + + + + + + + A Close B Back +=== removal-done 80x24 + RetroBIOS: Done + + Removed 2 files, 512.0 KB freed. + BIOS folder: ~/.config/retroarch/system + + > Close + Check again + + + + + + + + + + + + + + + + + A Close B Back +=== backups 40x12 + RetroBIOS: Backups + + Newest first. + + > 3 Nov 12:00 2 files, 512.0 KB + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + A Choose B Back +=== backups 40x15 + RetroBIOS: Backups + + Newest first. + + > 3 Nov 12:00 2 files, 512.0 KB + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + + + + A Choose B Back +=== backups 80x24 + RetroBIOS: Backups + + Newest first. + + > 3 Nov 12:00 2 files, 512.0 KB + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + + + + + + + + + + + + + A Choose B Back diff --git a/tests/fixtures/tui/route-gptokeyb.txt b/tests/fixtures/tui/route-gptokeyb.txt index 8c13bce43..e8753e477 100644 --- a/tests/fixtures/tui/route-gptokeyb.txt +++ b/tests/fixtures/tui/route-gptokeyb.txt @@ -1302,7 +1302,7 @@ Same choice as on 3 Nov: yes Choose systems Copy from an old card or a folder - 1-4 of 8 + 1-4 of 9 A Install B Back Y Change === plan-record 40x15 RetroBIOS: RetroArch @@ -1310,7 +1310,6 @@ 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 @@ -1318,6 +1317,7 @@ Change where Details Change + More Quit A Install B Back Y Change === plan-record 80x24 @@ -1334,6 +1334,7 @@ Change where Details Change + More Quit @@ -1343,5 +1344,328 @@ - A Install B Back Y Change +=== more 40x12 + RetroBIOS: More + + > Remove files + Backups + Put back a backup + Delete a backup + + + + + + A Open B Back +=== more 40x15 + RetroBIOS: More + + > Remove files + Backups + Put back a backup + Delete a backup + + + + + + + + + A Open B Back +=== more 80x24 + RetroBIOS: More + + > Remove files + Backups + Put back a backup + Delete a backup + + + + + + + + + + + + + + + + + + A Open B Back +=== remove 40x12 + RetroBIOS: Remove files + + Choose what to remove. A file changed + or added by hand stays. + + > All RetroBIOS files 528.0 KB + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + A Choose B Back +=== remove 40x15 + RetroBIOS: Remove files + + Choose what to remove. A file changed + or added by hand stays. + + > All RetroBIOS files 528.0 KB + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + + + + A Choose B Back +=== remove 80x24 + RetroBIOS: Remove files + + Choose what to remove. A file changed or added by hand stays. + + > All RetroBIOS files 528.0 KB + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + + + + + + + + + + + + + + A Choose B Back +=== removal 40x12 + RetroBIOS: Remove files + + 2 files go, 512.0 KB freed. + BIOS folder: + ~/.config/retroarch/system + 1 file stays: 1 changed since it was + installed. + + > Remove 512.0 KB + Keep a backup: no --backup + Details + A Remove B Back +=== removal 40x15 + RetroBIOS: Remove files + + 2 files go, 512.0 KB freed. + BIOS folder: + ~/.config/retroarch/system + 1 file stays: 1 changed since it was + installed. + + > Remove 512.0 KB + Keep a backup: no --backup + Details + + + + A Remove B Back +=== removal 80x24 + RetroBIOS: Remove files + + 2 files go, 512.0 KB freed. + BIOS folder: ~/.config/retroarch/system + 1 file stays: 1 changed since it was installed. + + > Remove 512.0 KB + Keep a backup: no --backup + Details + + + + + + + + + + + + + + + A Remove B Back +=== removal-confirm 40x12 + RetroBIOS: Confirm + + Remove 2 files for good? 512.0 KB is + freed. + + > Remove + Back + + + + + A Confirm B Back +=== removal-confirm 40x15 + RetroBIOS: Confirm + + Remove 2 files for good? 512.0 KB is + freed. + + > Remove + Back + + + + + + + + A Confirm B Back +=== removal-confirm 80x24 + RetroBIOS: Confirm + + Remove 2 files for good? 512.0 KB is freed. + + > Remove + Back + + + + + + + + + + + + + + + + + + A Confirm B Back +=== removal-done 40x12 + RetroBIOS: Done + + Removed 2 files, 512.0 KB freed. + BIOS folder: ~/.co.../retroarch/system + + > Close + Check again + + + + + A Close B Back +=== removal-done 40x15 + RetroBIOS: Done + + Removed 2 files, 512.0 KB freed. + BIOS folder: ~/.co.../retroarch/system + + > Close + Check again + + + + + + + + A Close B Back +=== removal-done 80x24 + RetroBIOS: Done + + Removed 2 files, 512.0 KB freed. + BIOS folder: ~/.config/retroarch/system + + > Close + Check again + + + + + + + + + + + + + + + + + A Close B Back +=== backups 40x12 + RetroBIOS: Backups + + Newest first. + + > 3 Nov 12:00 2 files, 512.0 KB + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + A Choose B Back +=== backups 40x15 + RetroBIOS: Backups + + Newest first. + + > 3 Nov 12:00 2 files, 512.0 KB + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + + + + A Choose B Back +=== backups 80x24 + RetroBIOS: Backups + + Newest first. + + > 3 Nov 12:00 2 files, 512.0 KB + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + + + + + + + + + + + + + A Choose B Back diff --git a/tests/fixtures/tui/route-gptokeyb_muxterm.txt b/tests/fixtures/tui/route-gptokeyb_muxterm.txt index 5c305378f..ed09cc872 100644 --- a/tests/fixtures/tui/route-gptokeyb_muxterm.txt +++ b/tests/fixtures/tui/route-gptokeyb_muxterm.txt @@ -1302,7 +1302,7 @@ Same choice as on 3 Nov: yes Choose systems Copy from an old card or a folder - 1-4 of 8 + 1-4 of 9 A Install B Back Y Change === plan-record 40x15 RetroBIOS: RetroArch @@ -1310,7 +1310,6 @@ 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 @@ -1318,6 +1317,7 @@ Change where Details Change + More Quit A Install B Back Y Change === plan-record 80x24 @@ -1334,6 +1334,7 @@ Change where Details Change + More Quit @@ -1343,5 +1344,328 @@ - A Install B Back Y Change +=== more 40x12 + RetroBIOS: More + + > Remove files + Backups + Put back a backup + Delete a backup + + + + + + A Open B Back +=== more 40x15 + RetroBIOS: More + + > Remove files + Backups + Put back a backup + Delete a backup + + + + + + + + + A Open B Back +=== more 80x24 + RetroBIOS: More + + > Remove files + Backups + Put back a backup + Delete a backup + + + + + + + + + + + + + + + + + + A Open B Back +=== remove 40x12 + RetroBIOS: Remove files + + Choose what to remove. A file changed + or added by hand stays. + + > All RetroBIOS files 528.0 KB + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + A Choose B Back +=== remove 40x15 + RetroBIOS: Remove files + + Choose what to remove. A file changed + or added by hand stays. + + > All RetroBIOS files 528.0 KB + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + + + + A Choose B Back +=== remove 80x24 + RetroBIOS: Remove files + + Choose what to remove. A file changed or added by hand stays. + + > All RetroBIOS files 528.0 KB + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + + + + + + + + + + + + + + A Choose B Back +=== removal 40x12 + RetroBIOS: Remove files + + 2 files go, 512.0 KB freed. + BIOS folder: + ~/.config/retroarch/system + 1 file stays: 1 changed since it was + installed. + + > Remove 512.0 KB + Keep a backup: no --backup + Details + A Remove B Back +=== removal 40x15 + RetroBIOS: Remove files + + 2 files go, 512.0 KB freed. + BIOS folder: + ~/.config/retroarch/system + 1 file stays: 1 changed since it was + installed. + + > Remove 512.0 KB + Keep a backup: no --backup + Details + + + + A Remove B Back +=== removal 80x24 + RetroBIOS: Remove files + + 2 files go, 512.0 KB freed. + BIOS folder: ~/.config/retroarch/system + 1 file stays: 1 changed since it was installed. + + > Remove 512.0 KB + Keep a backup: no --backup + Details + + + + + + + + + + + + + + + A Remove B Back +=== removal-confirm 40x12 + RetroBIOS: Confirm + + Remove 2 files for good? 512.0 KB is + freed. + + > Remove + Back + + + + + A Confirm B Back +=== removal-confirm 40x15 + RetroBIOS: Confirm + + Remove 2 files for good? 512.0 KB is + freed. + + > Remove + Back + + + + + + + + A Confirm B Back +=== removal-confirm 80x24 + RetroBIOS: Confirm + + Remove 2 files for good? 512.0 KB is freed. + + > Remove + Back + + + + + + + + + + + + + + + + + + A Confirm B Back +=== removal-done 40x12 + RetroBIOS: Done + + Removed 2 files, 512.0 KB freed. + BIOS folder: ~/.co.../retroarch/system + + > Close + Check again + + + + + A Close B Back +=== removal-done 40x15 + RetroBIOS: Done + + Removed 2 files, 512.0 KB freed. + BIOS folder: ~/.co.../retroarch/system + + > Close + Check again + + + + + + + + A Close B Back +=== removal-done 80x24 + RetroBIOS: Done + + Removed 2 files, 512.0 KB freed. + BIOS folder: ~/.config/retroarch/system + + > Close + Check again + + + + + + + + + + + + + + + + + A Close B Back +=== backups 40x12 + RetroBIOS: Backups + + Newest first. + + > 3 Nov 12:00 2 files, 512.0 KB + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + A Choose B Back +=== backups 40x15 + RetroBIOS: Backups + + Newest first. + + > 3 Nov 12:00 2 files, 512.0 KB + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + + + + A Choose B Back +=== backups 80x24 + RetroBIOS: Backups + + Newest first. + + > 3 Nov 12:00 2 files, 512.0 KB + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + + + + + + + + + + + + + A Choose B Back diff --git a/tests/fixtures/tui/route-keys.txt b/tests/fixtures/tui/route-keys.txt index d00c6633c..18fad0816 100644 --- a/tests/fixtures/tui/route-keys.txt +++ b/tests/fixtures/tui/route-keys.txt @@ -1467,7 +1467,7 @@ Same choice as on 3 Nov: yes Choose systems Copy from an old card or a folder - 1-4 of 8 + 1-4 of 9 Enter Install Esc Back o Change === plan-record 40x15 RetroBIOS: RetroArch @@ -1475,7 +1475,6 @@ 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 @@ -1483,6 +1482,7 @@ Change where Details Change + More Quit Enter Install Esc Back o Change === plan-record 80x24 @@ -1499,6 +1499,7 @@ Change where Details Change + More Quit @@ -1508,5 +1509,328 @@ - Enter Install Esc Back o Change ? Help +=== more 40x12 + RetroBIOS: More + + > Remove files + Backups + Put back a backup + Delete a backup + + + + + + Enter Open Esc Back ? Help +=== more 40x15 + RetroBIOS: More + + > Remove files + Backups + Put back a backup + Delete a backup + + + + + + + + + Enter Open Esc Back ? Help +=== more 80x24 + RetroBIOS: More + + > Remove files + Backups + Put back a backup + Delete a backup + + + + + + + + + + + + + + + + + + Enter Open Esc Back ? Help +=== remove 40x12 + RetroBIOS: Remove files + + Choose what to remove. A file changed + or added by hand stays. + + > All RetroBIOS files 528.0 KB + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + Enter Choose Esc Back ? Help +=== remove 40x15 + RetroBIOS: Remove files + + Choose what to remove. A file changed + or added by hand stays. + + > All RetroBIOS files 528.0 KB + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + + + + Enter Choose Esc Back ? Help +=== remove 80x24 + RetroBIOS: Remove files + + Choose what to remove. A file changed or added by hand stays. + + > All RetroBIOS files 528.0 KB + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + + + + + + + + + + + + + + Enter Choose Esc Back ? Help +=== removal 40x12 + RetroBIOS: Remove files + + 2 files go, 512.0 KB freed. + BIOS folder: + ~/.config/retroarch/system + 1 file stays: 1 changed since it was + installed. + + > Remove 512.0 KB + Keep a backup: no --backup + Details + Enter Remove Esc Back ? Help +=== removal 40x15 + RetroBIOS: Remove files + + 2 files go, 512.0 KB freed. + BIOS folder: + ~/.config/retroarch/system + 1 file stays: 1 changed since it was + installed. + + > Remove 512.0 KB + Keep a backup: no --backup + Details + + + + Enter Remove Esc Back ? Help +=== removal 80x24 + RetroBIOS: Remove files + + 2 files go, 512.0 KB freed. + BIOS folder: ~/.config/retroarch/system + 1 file stays: 1 changed since it was installed. + + > Remove 512.0 KB + Keep a backup: no --backup + Details + + + + + + + + + + + + + + + Enter Remove Esc Back ? Help +=== removal-confirm 40x12 + RetroBIOS: Confirm + + Remove 2 files for good? 512.0 KB is + freed. + + > Remove + Back + + + + + Enter Confirm Esc Back ? Help +=== removal-confirm 40x15 + RetroBIOS: Confirm + + Remove 2 files for good? 512.0 KB is + freed. + + > Remove + Back + + + + + + + + Enter Confirm Esc Back ? Help +=== removal-confirm 80x24 + RetroBIOS: Confirm + + Remove 2 files for good? 512.0 KB is freed. + + > Remove + Back + + + + + + + + + + + + + + + + + + Enter Confirm Esc Back ? Help +=== removal-done 40x12 + RetroBIOS: Done + + Removed 2 files, 512.0 KB freed. + BIOS folder: ~/.co.../retroarch/system + + > Close + Check again + + + + + Enter Close Esc Back ? Help +=== removal-done 40x15 + RetroBIOS: Done + + Removed 2 files, 512.0 KB freed. + BIOS folder: ~/.co.../retroarch/system + + > Close + Check again + + + + + + + + Enter Close Esc Back ? Help +=== removal-done 80x24 + RetroBIOS: Done + + Removed 2 files, 512.0 KB freed. + BIOS folder: ~/.config/retroarch/system + + > Close + Check again + + + + + + + + + + + + + + + + + Enter Close Esc Back ? Help +=== backups 40x12 + RetroBIOS: Backups + + Newest first. + + > 3 Nov 12:00 2 files, 512.0 KB + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + Enter Choose Esc Back ? Help +=== backups 40x15 + RetroBIOS: Backups + + Newest first. + + > 3 Nov 12:00 2 files, 512.0 KB + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + + + + Enter Choose Esc Back ? Help +=== backups 80x24 + RetroBIOS: Backups + + Newest first. + + > 3 Nov 12:00 2 files, 512.0 KB + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + + + + + + + + + + + + + Enter Choose Esc Back ? Help diff --git a/tests/fixtures/tui/route-miyoo.txt b/tests/fixtures/tui/route-miyoo.txt index d992c8764..6c7977088 100644 --- a/tests/fixtures/tui/route-miyoo.txt +++ b/tests/fixtures/tui/route-miyoo.txt @@ -1405,7 +1405,7 @@ Same choice as on 3 Nov: yes Choose systems Copy from an old card or a folder - 1-4 of 8 + 1-4 of 9 A Install R2 Back L2 Change === plan-record 40x15 RetroBIOS: RetroArch @@ -1413,7 +1413,6 @@ 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 @@ -1421,6 +1420,7 @@ Change where Details Change + More Quit A Install R2 Back L2 Change === plan-record 80x24 @@ -1437,6 +1437,7 @@ Change where Details Change + More Quit @@ -1446,5 +1447,328 @@ - A Install R2 Back L2 Change +=== more 40x12 + RetroBIOS: More + + > Remove files + Backups + Put back a backup + Delete a backup + + + + + + A Open R2 Back +=== more 40x15 + RetroBIOS: More + + > Remove files + Backups + Put back a backup + Delete a backup + + + + + + + + + A Open R2 Back +=== more 80x24 + RetroBIOS: More + + > Remove files + Backups + Put back a backup + Delete a backup + + + + + + + + + + + + + + + + + + A Open R2 Back +=== remove 40x12 + RetroBIOS: Remove files + + Choose what to remove. A file changed + or added by hand stays. + + > All RetroBIOS files 528.0 KB + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + A Choose R2 Back +=== remove 40x15 + RetroBIOS: Remove files + + Choose what to remove. A file changed + or added by hand stays. + + > All RetroBIOS files 528.0 KB + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + + + + A Choose R2 Back +=== remove 80x24 + RetroBIOS: Remove files + + Choose what to remove. A file changed or added by hand stays. + + > All RetroBIOS files 528.0 KB + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + + + + + + + + + + + + + + A Choose R2 Back +=== removal 40x12 + RetroBIOS: Remove files + + 2 files go, 512.0 KB freed. + BIOS folder: + ~/.config/retroarch/system + 1 file stays: 1 changed since it was + installed. + + > Remove 512.0 KB + Keep a backup: no --backup + Details + A Remove R2 Back +=== removal 40x15 + RetroBIOS: Remove files + + 2 files go, 512.0 KB freed. + BIOS folder: + ~/.config/retroarch/system + 1 file stays: 1 changed since it was + installed. + + > Remove 512.0 KB + Keep a backup: no --backup + Details + + + + A Remove R2 Back +=== removal 80x24 + RetroBIOS: Remove files + + 2 files go, 512.0 KB freed. + BIOS folder: ~/.config/retroarch/system + 1 file stays: 1 changed since it was installed. + + > Remove 512.0 KB + Keep a backup: no --backup + Details + + + + + + + + + + + + + + + A Remove R2 Back +=== removal-confirm 40x12 + RetroBIOS: Confirm + + Remove 2 files for good? 512.0 KB is + freed. + + > Remove + Back + + + + + A Confirm R2 Back +=== removal-confirm 40x15 + RetroBIOS: Confirm + + Remove 2 files for good? 512.0 KB is + freed. + + > Remove + Back + + + + + + + + A Confirm R2 Back +=== removal-confirm 80x24 + RetroBIOS: Confirm + + Remove 2 files for good? 512.0 KB is freed. + + > Remove + Back + + + + + + + + + + + + + + + + + + A Confirm R2 Back +=== removal-done 40x12 + RetroBIOS: Done + + Removed 2 files, 512.0 KB freed. + BIOS folder: ~/.co.../retroarch/system + + > Close + Check again + + + + + A Close R2 Back +=== removal-done 40x15 + RetroBIOS: Done + + Removed 2 files, 512.0 KB freed. + BIOS folder: ~/.co.../retroarch/system + + > Close + Check again + + + + + + + + A Close R2 Back +=== removal-done 80x24 + RetroBIOS: Done + + Removed 2 files, 512.0 KB freed. + BIOS folder: ~/.config/retroarch/system + + > Close + Check again + + + + + + + + + + + + + + + + + A Close R2 Back +=== backups 40x12 + RetroBIOS: Backups + + Newest first. + + > 3 Nov 12:00 2 files, 512.0 KB + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + A Choose R2 Back +=== backups 40x15 + RetroBIOS: Backups + + Newest first. + + > 3 Nov 12:00 2 files, 512.0 KB + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + + + + A Choose R2 Back +=== backups 80x24 + RetroBIOS: Backups + + Newest first. + + > 3 Nov 12:00 2 files, 512.0 KB + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + + + + + + + + + + + + + A Choose R2 Back diff --git a/tests/fixtures/tui/route-muxterm.txt b/tests/fixtures/tui/route-muxterm.txt index 15e6e44e9..9e9cf8ab4 100644 --- a/tests/fixtures/tui/route-muxterm.txt +++ b/tests/fixtures/tui/route-muxterm.txt @@ -1521,7 +1521,7 @@ Same choice as on 3 Nov: yes Choose systems Copy from an old card or a folder - 1-4 of 8 + 1-4 of 9 Start Install B Back === plan-record 40x15 RetroBIOS: RetroArch @@ -1529,7 +1529,6 @@ 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 @@ -1537,6 +1536,7 @@ Change where Details Change + More Quit Start Install B Back === plan-record 80x24 @@ -1553,6 +1553,7 @@ Change where Details Change + More Quit @@ -1562,5 +1563,328 @@ - Start Install B Back +=== more 40x12 + RetroBIOS: More + + > Remove files + Backups + Put back a backup + Delete a backup + + + + + + Start Open B Back +=== more 40x15 + RetroBIOS: More + + > Remove files + Backups + Put back a backup + Delete a backup + + + + + + + + + Start Open B Back +=== more 80x24 + RetroBIOS: More + + > Remove files + Backups + Put back a backup + Delete a backup + + + + + + + + + + + + + + + + + + Start Open B Back +=== remove 40x12 + RetroBIOS: Remove files + + Choose what to remove. A file changed + or added by hand stays. + + > All RetroBIOS files 528.0 KB + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + Start Choose B Back +=== remove 40x15 + RetroBIOS: Remove files + + Choose what to remove. A file changed + or added by hand stays. + + > All RetroBIOS files 528.0 KB + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + + + + Start Choose B Back +=== remove 80x24 + RetroBIOS: Remove files + + Choose what to remove. A file changed or added by hand stays. + + > All RetroBIOS files 528.0 KB + nintendo-gba 16.0 KB + sony-playstation 512.0 KB + + + + + + + + + + + + + + + + + Start Choose B Back +=== removal 40x12 + RetroBIOS: Remove files + + 2 files go, 512.0 KB freed. + BIOS folder: + ~/.config/retroarch/system + 1 file stays: 1 changed since it was + installed. + + > Remove 512.0 KB + Keep a backup: no --backup + Details + Start Remove B Back +=== removal 40x15 + RetroBIOS: Remove files + + 2 files go, 512.0 KB freed. + BIOS folder: + ~/.config/retroarch/system + 1 file stays: 1 changed since it was + installed. + + > Remove 512.0 KB + Keep a backup: no --backup + Details + + + + Start Remove B Back +=== removal 80x24 + RetroBIOS: Remove files + + 2 files go, 512.0 KB freed. + BIOS folder: ~/.config/retroarch/system + 1 file stays: 1 changed since it was installed. + + > Remove 512.0 KB + Keep a backup: no --backup + Details + + + + + + + + + + + + + + + Start Remove B Back +=== removal-confirm 40x12 + RetroBIOS: Confirm + + Remove 2 files for good? 512.0 KB is + freed. + + > Remove + Back + + + + + Start Confirm B Back +=== removal-confirm 40x15 + RetroBIOS: Confirm + + Remove 2 files for good? 512.0 KB is + freed. + + > Remove + Back + + + + + + + + Start Confirm B Back +=== removal-confirm 80x24 + RetroBIOS: Confirm + + Remove 2 files for good? 512.0 KB is freed. + + > Remove + Back + + + + + + + + + + + + + + + + + + Start Confirm B Back +=== removal-done 40x12 + RetroBIOS: Done + + Removed 2 files, 512.0 KB freed. + BIOS folder: ~/.co.../retroarch/system + + > Close + Check again + + + + + Start Close B Back +=== removal-done 40x15 + RetroBIOS: Done + + Removed 2 files, 512.0 KB freed. + BIOS folder: ~/.co.../retroarch/system + + > Close + Check again + + + + + + + + Start Close B Back +=== removal-done 80x24 + RetroBIOS: Done + + Removed 2 files, 512.0 KB freed. + BIOS folder: ~/.config/retroarch/system + + > Close + Check again + + + + + + + + + + + + + + + + + Start Close B Back +=== backups 40x12 + RetroBIOS: Backups + + Newest first. + + > 3 Nov 12:00 2 files, 512.0 KB + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + Start Choose B Back +=== backups 40x15 + RetroBIOS: Backups + + Newest first. + + > 3 Nov 12:00 2 files, 512.0 KB + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + + + + Start Choose B Back +=== backups 80x24 + RetroBIOS: Backups + + Newest first. + + > 3 Nov 12:00 2 files, 512.0 KB + 20 Oct 09:30 1 file, 16.0 KB + Back + + + + + + + + + + + + + + + + + Start Choose B Back diff --git a/tests/test_install_journeys.py b/tests/test_install_journeys.py index 08b057989..35a3bf388 100644 --- a/tests/test_install_journeys.py +++ b/tests/test_install_journeys.py @@ -63,9 +63,11 @@ JOURNEY_KEYS = { JOURNEY_WORDS = { "first_install": 47, "card_with_games": 87, - "update": 70, + # A plan whose folder has a record names More (one word): an update + # and a second run of one system. + "update": 71, "one_system": 179, - "one_system_again": 67, + "one_system_again": 68, # Change holds every option one level down now (design 4.4): its # headings and the rows for Emulators, a pack, the device and the # internet, the intro sentence dropped, 10 words on each of its two diff --git a/tests/test_install_removal.py b/tests/test_install_removal.py new file mode 100644 index 000000000..56c454f30 --- /dev/null +++ b/tests/test_install_removal.py @@ -0,0 +1,470 @@ +"""Removing what the installer placed, and putting a backup back. + +Removal deletes for good by default, or moves into a backup with --backup. +Only the install record says what may go: a file it lists, still its +recorded bytes, that the collection can serve again. Each test drives the +real install.py against a loopback repository, or the planner directly for +records no install writes (a crafted one, a drive that folds case). +""" + +from __future__ import annotations + +import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache + +import contextlib +import hashlib +import io +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) +sys.path.insert(0, str(REPO_ROOT / "tests")) + +import install # noqa: E402 +from test_install_selection import FakeRepository # noqa: E402 + +try: + import pty +except ImportError: # Windows has no pseudo-terminal module. + pty = None + + +class SharedRepository(FakeRepository): + """The loopback repository with a file two systems read.""" + + PAYLOADS = {**FakeRepository.PAYLOADS, + "shared.bin": (b"shared", ["sony-playstation", "nintendo-gba"], None, None)} + + def run(self, *options: str, stdin: str | None = None) -> subprocess.CompletedProcess: + # --restore and --delete-backup name their folder alone. + command = self.command(*options) + if options[0] in ("--restore", "--delete-backup"): + command = [*command[:2], *options] + return subprocess.run( + command, env=self.env(), capture_output=True, text=True, + timeout=60, check=False, input=stdin, + stdin=None if stdin is not None else subprocess.DEVNULL, + ) + + def answer(self, reply: str, *options: str) -> tuple[int, str]: + """A run at a terminal that answers reply to its question: (code, text).""" + main, other = pty.openpty() + try: + proc = subprocess.Popen(self.command(*options), env=self.env(), stdin=other, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + os.write(main, reply.encode() + b"\n") + out, _err = proc.communicate(timeout=60) + finally: + os.close(main) + os.close(other) + return proc.returncode, out.decode() + + @property + def record(self) -> Path: + return self.dest.parent / install.BACKUP_DIR / f"{self.dest.name}.install.json" + + def snapshot(self) -> dict[str, bytes]: + return {path.relative_to(self.dest).as_posix(): path.read_bytes() + for path in sorted(self.dest.rglob("*")) if path.is_file()} + + def events(self, proc: subprocess.CompletedProcess) -> list[dict]: + return [json.loads(line) for line in proc.stdout.splitlines() if line.startswith("{")] + + def setup(self) -> dict: + return json.loads(self.record.read_text())["setups"]["retroarch"] + + def install_both(self) -> None: + proc = self.run("--system", "sony-playstation", "--system", "nintendo-gba", "--yes") + assert proc.returncode == 0, proc.stdout + proc.stderr + + +GBA_ONLY = ["gba_bios.bin", "mgba/extra.bin"] +PSX = ["pcsx/extra.bin", "scph5500.bin", "scph5501.bin"] + + +class RemoveOneOfTwoSystems(unittest.TestCase): + def test_the_shared_file_stays_and_a_backup_puts_the_folder_back(self): + with SharedRepository() as repo: + repo.install_both() + before = repo.snapshot() + self.assertEqual(sorted(before), sorted(GBA_ONLY + PSX + ["shared.bin"])) + proc = repo.run("--remove", "--system", "nintendo-gba", "--backup", "--yes") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertEqual(sorted(repo.snapshot()), sorted(PSX + ["shared.bin"])) + self.assertIn("shared.bin: still needed by sony-playstation", proc.stdout) + setup = repo.setup() + self.assertEqual(setup["choice"], ["--system", "sony-playstation"]) + self.assertEqual(sorted(item["dest"] for item in setup["files"]), + sorted(PSX + ["shared.bin"])) + [backup] = install.list_backups(repo.dest) + self.assertEqual((backup["files"], backup["source"]), (2, "remove")) + proc = repo.run("--restore", str(backup["folder"])) + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertEqual(repo.snapshot(), before) + setup = repo.setup() + self.assertEqual(sorted(item["dest"] for item in setup["files"]), sorted(before)) + self.assertEqual(install.record_options(setup["choice"]), + [("--system", "sony-playstation"), ("--system", "nintendo-gba")]) + + def test_everything_into_a_backup_and_back_with_its_record(self): + with SharedRepository() as repo: + repo.install_both() + before, setup = repo.snapshot(), repo.setup() + proc = repo.run("--remove", "--backup", "--yes") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertEqual((repo.snapshot(), repo.record.exists()), ({}, False)) + [backup] = install.list_backups(repo.dest) + self.assertEqual(repo.run("--restore", str(backup["folder"])).returncode, 0) + self.assertEqual(repo.snapshot(), before) + self.assertEqual((repo.setup()["choice"], repo.setup()["files"]), + (setup["choice"], setup["files"])) + + def test_removal_deletes_for_good_by_default(self): + with SharedRepository() as repo: + repo.install_both() + proc = repo.run("--remove", "--system", "nintendo-gba", "--yes") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertIn("Removed 2 files, 12 B freed.", proc.stdout) + self.assertEqual(install.list_backups(repo.dest), []) + holder = repo.dest.parent / install.BACKUP_DIR + self.assertEqual(sorted(path.name for path in holder.iterdir()), + [f"{repo.dest.name}.install.json"]) + + def test_removing_everything_leaves_no_record(self): + with SharedRepository() as repo: + repo.install_both() + proc = repo.run("--remove", "--yes") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertEqual(repo.snapshot(), {}) + self.assertFalse(repo.record.exists()) + + def test_dry_run_lists_and_removes_nothing(self): + with SharedRepository() as repo: + repo.install_both() + before = repo.snapshot() + proc = repo.run("--remove", "--system", "nintendo-gba", "--dry-run") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + for dest in GBA_ONLY: + self.assertIn(f" {dest}\n", proc.stdout) + self.assertIn("Nothing was removed (--dry-run).", proc.stdout) + self.assertEqual(repo.snapshot(), before) + + def test_one_file(self): + with SharedRepository() as repo: + repo.install_both() + proc = repo.run("--remove", "--file", "gba_bios.bin", "--file", + str(repo.dest / "not-ours.bin"), "--yes") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertNotIn("gba_bios.bin", repo.snapshot()) + self.assertIn("not-ours.bin: not installed by RetroBIOS", proc.stdout) + outside = repo.run("--remove", "--file", str(repo.root / "x.bin"), "--yes") + self.assertEqual(outside.returncode, 2) + self.assertIn("is not inside", outside.stderr) + + def test_a_core(self): + with SharedRepository() as repo: + repo.install_both() + proc = repo.run("--remove", "--core", "mgba", "--yes") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertEqual(sorted(repo.snapshot()), + sorted(["gba_bios.bin", "shared.bin"] + PSX)) + self.assertIn(("--core", "pcsx_rearmed"), + install.record_options(repo.setup()["choice"])) + + +class NeverTouched(unittest.TestCase): + def test_a_changed_file_and_one_added_by_hand_stay(self): + with SharedRepository() as repo: + repo.install_both() + (repo.dest / "scph5501.bin").write_bytes(b"the user's own") + (repo.dest / "mine.bin").write_bytes(b"added by hand") + proc = repo.run("--remove", "--yes") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertEqual(repo.snapshot(), {"scph5501.bin": b"the user's own", + "mine.bin": b"added by hand"}) + self.assertIn("1 file stays: 1 changed since it was installed.", proc.stdout) + self.assertIn("scph5501.bin: changed since it was installed", proc.stdout) + self.assertIn("1 other file stays, not installed by RetroBIOS.", proc.stdout) + + def test_a_link_in_place_of_a_file_stays_and_its_target_too(self): + with SharedRepository() as repo: + repo.install_both() + target = repo.root / "elsewhere.bin" + shutil.copy(repo.dest / "gba_bios.bin", target) + (repo.dest / "gba_bios.bin").unlink() + (repo.dest / "gba_bios.bin").symlink_to(target) + proc = repo.run("--remove", "--system", "nintendo-gba", "--yes") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertTrue((repo.dest / "gba_bios.bin").is_symlink()) + self.assertTrue(target.is_file()) + self.assertIn("gba_bios.bin: a link, never followed", proc.stdout) + + +class ExitCodes(unittest.TestCase): + @unittest.skipIf(pty is None, "a question needs a pseudo-terminal") + def test_zero_one_and_two(self): + with SharedRepository() as repo: + no_record = repo.run("--remove", "--yes") + self.assertEqual(no_record.returncode, 2) + repo.install_both() + asked = repo.run("--remove") + self.assertEqual(asked.returncode, 2) + self.assertIn("add --yes", asked.stderr) + declined, said = repo.answer("n", "--remove") + self.assertEqual(declined, 2, said) + self.assertIn("Remove them for good? [y/N]", said) + self.assertIn("Nothing was removed.", said) + self.assertEqual(len(repo.installed()), 6) + if not (hasattr(os, "geteuid") and os.geteuid() == 0): + (repo.dest / "pcsx").chmod(0o555) + try: + failed = repo.run("--remove", "--system", "sony-playstation", "--yes") + finally: + (repo.dest / "pcsx").chmod(0o755) + self.assertEqual(failed.returncode, 1, failed.stdout + failed.stderr) + self.assertIn("failed: pcsx/extra.bin", failed.stderr) + # The record keeps what is still there. + self.assertIn("pcsx/extra.bin", + [item["dest"] for item in repo.setup()["files"]]) + done = repo.run("--remove", "--yes") + self.assertEqual(done.returncode, 0, done.stdout + done.stderr) + nothing = repo.run("--remove", "--yes") + self.assertEqual(nothing.returncode, 2) + + def test_options_an_action_does_not_read_are_refused(self): + with SharedRepository() as repo: + for argv, words in ( + (("--file", "a.bin"), "--file applies to --remove"), + (("--backup",), "--backup applies to --remove"), + (("--remove", "--region", "us"), "--remove takes no --region"), + (("--remove", "--system", "nintendo-gba", "--core", "mgba"), + "not --system and --core"), + (("--remove", "--backups"), "two actions"), + (("--backups", "--yes"), "--backups takes no --yes"), + ): + with self.subTest(argv=argv): + proc = repo.run(*argv) + self.assertEqual(proc.returncode, 2) + self.assertIn(words, proc.stderr) + + +class WithoutARecord(unittest.TestCase): + def test_a_missing_or_damaged_record_removes_nothing_in_one_line(self): + with SharedRepository() as repo: + repo.install_both() + before = repo.snapshot() + repo.record.write_text("{not json") + proc = repo.run("--remove", "--yes") + self.assertEqual(proc.returncode, 2) + self.assertEqual(repo.snapshot(), before) + self.assertEqual(proc.stderr.strip().count("\n"), 0, proc.stderr) + self.assertIn("Nothing was removed", proc.stderr) + self.assertIn("is damaged", proc.stderr) + repo.record.unlink() + proc = repo.run("--remove", "--yes") + self.assertEqual(proc.returncode, 2) + self.assertEqual(proc.stderr.strip().count("\n"), 0, proc.stderr) + self.assertIn("no install record", proc.stderr) + self.assertEqual(repo.snapshot(), before) + + +class Events(unittest.TestCase): + def test_every_new_kind(self): + with SharedRepository() as repo: + repo.install_both() + plan = repo.events(repo.run("--remove", "--system", "nintendo-gba", "--dry-run", + "--events")) + self.assertEqual([event["event"] for event in plan], + ["start", "removal_plan", "end"]) + self.assertEqual(plan[0]["mode"], "remove") + self.assertEqual((plan[1]["files"], plan[1]["bytes"], plan[1]["dests"]), + (2, 12, GBA_ONLY)) + self.assertEqual(plan[1]["kept"], [{"dest": "shared.bin", "reason": "shared"}]) + run = repo.events(repo.run("--remove", "--system", "nintendo-gba", "--backup", + "--events", "--yes")) + kinds = [event["event"] for event in run] + self.assertEqual(kinds, ["start", "removal_plan", "removed", "removed", "backup", + "end"]) + self.assertTrue(all(event["ok"] and event["moved_to"] + for event in run if event["event"] == "removed")) + self.assertEqual(run[4]["status"], "made") + listed = repo.events(repo.run("--backups", "--events")) + self.assertEqual([event["event"] for event in listed], ["start", "backup", "end"]) + self.assertEqual((listed[0]["mode"], listed[1]["status"], listed[1]["files"]), + ("list", "listed", 2)) + folder = listed[1]["folder"] + back = repo.events(repo.run("--restore", folder, "--events")) + self.assertEqual([event["event"] for event in back], + ["start", "restored", "restored", "end"]) + self.assertEqual(back[0]["mode"], "restore") + gone = repo.events(repo.run("--delete-backup", folder, "--events", "--yes")) + self.assertEqual([event["event"] for event in gone], ["start", "backup", "end"]) + self.assertEqual((gone[1]["status"], gone[2]["code"]), ("deleted", 0)) + self.assertFalse(Path(folder).exists()) + for event in plan + run + listed + back + gone: + self.assertEqual(sorted(event), sorted( + ["event", *install.EVENT_KEYS[event["event"]]])) + + +class DeleteABackup(unittest.TestCase): + def test_only_a_backup_folder_and_only_with_a_yes(self): + with SharedRepository() as repo: + repo.install_both() + repo.run("--remove", "--system", "nintendo-gba", "--backup", "--yes") + [backup] = install.list_backups(repo.dest) + asked = repo.run("--delete-backup", str(backup["folder"])) + self.assertEqual(asked.returncode, 2) + self.assertTrue(backup["folder"].is_dir()) + refused = repo.run("--delete-backup", str(repo.dest), "--yes") + self.assertEqual(refused.returncode, 2) + self.assertTrue((repo.dest / "shared.bin").is_file()) + done = repo.run("--delete-backup", str(backup["folder"]), "--yes") + self.assertEqual(done.returncode, 0, done.stdout + done.stderr) + self.assertFalse(backup["folder"].exists()) + + +def _record(folder: Path, files: list[dict], others: dict | None = None, + choice: list[str] | None = None) -> dict: + setup = {"written": "2026-11-03T12:00:00Z", "list": {"version": "v1", "generated": ""}, + "choice": choice or ["--all-files"], "files": files} + return {"schema": 1, "folder": str(folder), "setups": {"retroarch": setup, **(others or {})}} + + +def _item(dest: str, payload: bytes, root: str = "") -> dict: + return {"dest": dest, "root": root, "sha1": hashlib.sha1(payload).hexdigest(), + "size": len(payload), "from": "download"} + + +def _served(*dests_payloads: tuple[str, bytes]) -> dict: + return {"files": [{"dest": dest, "sha1": hashlib.sha1(payload).hexdigest(), + "size": len(payload), "cores": None, "systems": ["nintendo-gba"]} + for dest, payload in dests_payloads]} + + +class ACraftedRecord(unittest.TestCase): + """A record anyone could write never makes the installer delete outside + the BIOS folder, through a link, or anything but a regular file.""" + + def setUp(self): + self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, self.root, True) + self.folder = self.root / "bios" + self.folder.mkdir() + + @unittest.skipUnless(hasattr(os, "mkfifo"), "a FIFO is POSIX") + def test_nothing_outside_or_through_a_link_is_removed(self): + payload = b"precious" + (self.root / "outside.bin").write_bytes(payload) + elsewhere = self.root / "elsewhere" + elsewhere.mkdir() + (elsewhere / "deep.bin").write_bytes(payload) + (self.folder / "linked").symlink_to(elsewhere) + (self.folder / "link.bin").symlink_to(self.root / "outside.bin") + os.mkfifo(self.folder / "pipe.bin") + dests = ["../outside.bin", str(self.root / "outside.bin"), "linked/deep.bin", + "link.bin", "pipe.bin", "a/../../outside.bin", "C:/outside.bin"] + record = _record(self.folder, [_item(dest, payload) for dest in dests] + + [_item("saved.bin", payload, "save")]) + manifest = _served(*[(dest, payload) for dest in [*dests, "saved.bin"]]) + removal = install.plan_removal("retroarch", self.folder, record, manifest) + self.assertEqual(removal.remove, []) + reasons = {item["dest"]: item["reason"] for item in removal.kept} + self.assertEqual(reasons, { + "../outside.bin": "outside", str(self.root / "outside.bin"): "outside", + "linked/deep.bin": "link", "link.bin": "link", "pipe.bin": "not_a_file", + "a/../../outside.bin": "outside", "C:/outside.bin": "outside", + "saved.bin": "outside"}) + install.remove_files(removal, record["setups"]["retroarch"], False) + self.assertEqual((self.root / "outside.bin").read_bytes(), payload) + self.assertEqual((elsewhere / "deep.bin").read_bytes(), payload) + + def test_a_file_swapped_after_the_plan_stays(self): + payload = b"ours" + (self.folder / "a.bin").write_bytes(payload) + record = _record(self.folder, [_item("a.bin", payload)]) + removal = install.plan_removal("retroarch", self.folder, record, + _served(("a.bin", payload))) + self.assertEqual(len(removal.remove), 1) + (self.folder / "a.bin").unlink() + (self.folder / "a.bin").symlink_to(self.root / "nowhere") + removed, failed, _run = install.remove_files(removal, record["setups"]["retroarch"], + False) + self.assertEqual((removed, [item["dest"] for item in failed]), ([], ["a.bin"])) + self.assertTrue((self.folder / "a.bin").is_symlink()) + + def test_a_file_the_list_no_longer_serves_stays(self): + payload = b"a key the user supplied" + (self.folder / "keys.bin").write_bytes(payload) + record = _record(self.folder, [_item("keys.bin", payload)]) + removal = install.plan_removal("retroarch", self.folder, record, {"files": []}) + self.assertEqual([item["reason"] for item in removal.kept], ["not_servable"]) + other = install.plan_removal("retroarch", self.folder, record, + _served(("keys.bin", b"another release"))) + self.assertEqual([item["reason"] for item in other.kept], ["not_servable"]) + + +class AFoldingDrive(unittest.TestCase): + """FAT and exFAT open a name whatever its case: the record, the list and + another setup are compared folded there.""" + + def setUp(self): + self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, self.root, True) + self.folder = self.root / "bios" + self.folder.mkdir() + self.payload = b"bios" + (self.folder / "SCPH5501.BIN").write_bytes(self.payload) + + def plan(self, keeps_case: bool, others: dict | None = None) -> install.Removal: + record = _record(self.folder, [_item("SCPH5501.BIN", self.payload)], others) + with mock.patch.object(install, "keeps_case", return_value=keeps_case): + return install.plan_removal("retroarch", self.folder, record, + _served(("scph5501.bin", self.payload))) + + def test_the_list_s_name_in_another_case_is_the_same_file(self): + self.assertEqual([entry["dest"] for entry in self.plan(False).remove], + ["SCPH5501.BIN"]) + self.assertEqual([item["reason"] for item in self.plan(True).kept], ["not_servable"]) + + def test_another_setup_s_file_in_another_case_stays(self): + other = {"lakka": {"written": "", "choice": ["--all-files"], + "files": [_item("scph5501.bin", self.payload)]}} + removal = self.plan(False, other) + self.assertEqual([(item["reason"], item["what"]) for item in removal.kept], + [("shared", "Lakka")]) + + def test_the_removal_runs_on_a_folding_drive(self): + removal = self.plan(False) + record = _record(self.folder, [_item("SCPH5501.BIN", self.payload)]) + with contextlib.redirect_stdout(io.StringIO()): + removed, failed, _run = install.remove_files( + removal, record["setups"]["retroarch"], False) + self.assertEqual((len(removed), failed), (1, [])) + self.assertFalse((self.folder / "SCPH5501.BIN").exists()) + + +class RestoreAnInstallBackup(unittest.TestCase): + def test_the_record_lets_go_of_the_user_s_files_put_back(self): + with SharedRepository() as repo: + repo.dest.mkdir(parents=True) + (repo.dest / "gba_bios.bin").write_bytes(b"the user's own") + repo.install_both() + [backup] = install.list_backups(repo.dest) + self.assertEqual(backup["source"], "install") + self.assertIn("gba_bios.bin", [item["dest"] for item in repo.setup()["files"]]) + proc = repo.run("--restore", str(backup["folder"])) + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertEqual((repo.dest / "gba_bios.bin").read_bytes(), b"the user's own") + self.assertNotIn("gba_bios.bin", [item["dest"] for item in repo.setup()["files"]]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 117e00c6d..0c99e2528 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -1063,9 +1063,83 @@ def golden_states() -> dict: told.stack.append("details") states["result-details"] = told states["plan-record"] = record_state() + states["more"] = more_state("more") + states["remove"] = more_state("more", "remove") + states["removal"] = removal_state() + states["removal-confirm"] = removal_state() + install.tui_update(states["removal-confirm"], Key("enter")) + states["removal-done"] = removal_done_state() + states["backups"] = backups_state() return states +# What an install of the PlayStation and the Game Boy Advance recorded. +MORE_RECORD = {"written": "2026-11-03T12:00:00Z", "list": {"version": "v1", "generated": ""}, + "choice": ["--all-files"], + "files": [{"dest": dest, "root": "", "sha1": "0" * 40, "size": size, + "from": "download"} + for dest, size in (("gba_bios.bin", 16384), ("pcsx/extra.bin", 4), + ("scph5500.bin", 524288))]} +REMOVAL_PLAN = {"event": "removal_plan", "platform": "retroarch", "folder": str(SYSTEM_DIR), + "files": 2, "bytes": 524292, "systems": ["sony-playstation"], + "dests": ["pcsx/extra.bin", "scph5500.bin"], + "kept": [{"dest": "scph5501.bin", "reason": "changed"}], "gone": 0, + "backup": False, "message": ""} +BACKUPS = [{"event": "backup", "folder": str(HOME / ".config" / "retroarch" / "retrobios-backup" + / name), "date": date, "files": files, + "bytes": size, "source": source, "status": "listed", "message": ""} + for name, date, files, size, source in ( + ("20261103-120000", "2026-11-03 12:00:00", 2, 524292, "remove"), + ("20261020-093000", "2026-10-20 09:30:00", 1, 16384, "install"))] + + +def more_state(*screens: str) -> install.TuiState: + """The plan of a folder whose record holds three files, More opened.""" + state = checked_state() + state.record = json.loads(json.dumps(MORE_RECORD)) + for screen in screens: + rows = install.tui_rows(state) + state.focus[state.screen] = [row.action for row in rows].index(screen) + assert install.tui_update(state, Key("enter")) == [], screen + return state + + +def removal_state(system: bool = True) -> install.TuiState: + """The removal plan of the PlayStation, read by its dry run.""" + state = more_state("more", "remove") + values = [row.value for row in install.tui_rows(state)] + state.focus["remove"] = values.index("sony-playstation") if system else 0 + effects = install.tui_update(state, Key("enter")) + assert effects == [("run", "removal_plan")], effects + install.tui_receive(state, ("event", state.run_id, REMOVAL_PLAN)) + install.tui_receive(state, ("ended", state.run_id, 0, [])) + return state + + +def removal_done_state() -> install.TuiState: + state = removal_state() + install.tui_update(state, Key("enter")) + effects = install.tui_update(state, Key("enter")) + assert effects == [("run", "remove")], effects + for dest, size in (("pcsx/extra.bin", 4), ("scph5500.bin", 524288)): + install.tui_receive(state, ("event", state.run_id, { + "event": "removed", "platform": "retroarch", "dest": dest, "size": size, + "ok": True, "reason": "", "moved_to": ""})) + install.tui_receive(state, ("ended", state.run_id, 0, [])) + return state + + +def backups_state(purpose: str = "backups") -> install.TuiState: + state = more_state("more") + state.focus["more"] = [row.action for row in install.tui_rows(state)].index(purpose) + effects = install.tui_update(state, Key("enter")) + assert effects == [("run", "backups")], effects + for event in BACKUPS: + install.tui_receive(state, ("event", state.run_id, event)) + install.tui_receive(state, ("ended", state.run_id, 0, [])) + return state + + # A board list as install/targets/ publishes it, read by Device: the boards # by the names the list gives them, the device images each one runs. TARGETS = {"bcm2711": {"cores": ["pcsx_rearmed"], "images": ["rpi4"]}, @@ -1187,7 +1261,7 @@ class RecordedPlan(unittest.TestCase): 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") + press(state, "end", "up", "up", "up", "up") self.assertEqual(install.tui_rows(state)[state.focus["plan"]].label, "Change where") press(state, "enter") self.assertEqual(state.screen, "setup") @@ -15182,8 +15256,9 @@ SCREEN_WORDS = { "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), + # the plan having opened on the one found), and More, the record being + # what a removal reads. + "plan-record": (38, 42, 43, 43), # Change holds every option one level below the plan (design 4.4): the # rows for Emulators, a pack, the device and the internet, and the five # headings that group them, the intro sentence they replace dropped. @@ -15191,6 +15266,14 @@ SCREEN_WORDS = { # A list of boards and one of emulators, each with the line that says # what the choice does and the gloss of "board". "device": (35, 35, 33, 33), "emulators": (29, 29, 27, 27), + # A plan names More (one word) where the folder has a record or a + # backup (design 4.3), the first install's plan unchanged. More's + # four rows, Remove files with each system the record holds and its + # size, the removal plan (what goes, the space freed, the folder, what + # stays and why), its confirm and result, and the backups newest first. + "more": (17, 17, 15, 15), "remove": (31, 31, 29, 29), "removal": (34, 34, 32, 32), + "removal-confirm": (18, 18, 16, 16), "removal-done": (18, 18, 16, 16), + "backups": (24, 24, 22, 22), } @@ -15200,6 +15283,121 @@ def screen_words(lines: list) -> int: if re.search(r"[A-Za-z0-9]", token) and "/" not in token) +class MoreScreens(unittest.TestCase): + """More: removal, the backups, putting one back and deleting one, each + a child run with its own options and none of the plan's.""" + + def setUp(self): + patcher = mock.patch.object(install, "_mounted_cards", return_value=[]) + patcher.start() + self.addCleanup(patcher.stop) + + def test_a_plan_names_more_where_it_has_something_to_act_on(self): + self.assertNotIn("more", [row.action for row in install.tui_rows(checked_state())]) + self.assertEqual([row.action for row in install.tui_rows(more_state())][-2:], + ["more", "quit"]) + done = golden_states()["plan-in-place"] + done.has_backups = True + self.assertEqual(install.tui_rows(done)[-1].action, "more") + + def test_the_removal_plan_asks_its_own_child(self): + state = removal_state() + state.regions = ["japan"] + argv = install.tui_argv(state, "removal_plan", "python", "install.py") + self.assertEqual(argv[-4:], ["--remove", "--system=sony-playstation", "--dry-run", + "--events"]) + self.assertNotIn("--region=japan", argv) + self.assertIn(f"--dest={SYSTEM_DIR}", argv) + self.assertEqual(state.screen, "removal") + self.assertEqual(install.tui_rows(state)[0].label, "Remove 512.0 KB") + + def test_everything_removes_with_no_system(self): + state = removal_state(system=False) + argv = install.tui_argv(state, "removal_plan", "python", "install.py") + self.assertEqual(argv[-3:], ["--remove", "--dry-run", "--events"]) + + def test_a_backup_is_one_row_away_and_the_removal_says_so(self): + state = removal_state() + state.focus["removal"] = 1 + press(state, "enter") + self.assertTrue(state.removal_backup) + self.assertEqual(install.tui_rows(state)[0].label, "Move 512.0 KB to a backup") + state.focus["removal"] = 0 + press(state, "enter") + self.assertEqual(state.screen, "actconfirm") + effects = press(state, "enter") + self.assertEqual(effects, [("run", "remove")]) + argv = install.tui_argv(state, "remove", "python", "install.py") + self.assertEqual(argv[-4:], ["--system=sony-playstation", "--backup", "--events", + "--yes"]) + + def test_close_ends_with_the_removal_s_code_and_back_counts_again(self): + state = removal_done_state() + self.assertEqual(state.screen, "actdone") + self.assertIn("Removed 2 files, 512.0 KB freed.", + screen_text(install.tui_view(state, 80, 24))) + press(state, "enter") + self.assertEqual(state.exit_code, 0) + state = removal_done_state() + with mock.patch.object(install, "setup_record", return_value=(None, None, "")): + effects = press(state, "esc") + self.assertEqual(effects, [("run", "check")]) + self.assertEqual(state.screen, "checking") + # The record is read again: the removal changed it. + self.assertIsNone(state.record) + + def test_a_failure_is_said_and_ends_on_one(self): + state = removal_state() + press(state, "enter", "enter") + install.tui_receive(state, ("event", state.run_id, { + "event": "removed", "platform": "retroarch", "dest": "scph5500.bin", + "size": 524288, "ok": False, "reason": "Permission denied", "moved_to": ""})) + install.tui_receive(state, ("ended", state.run_id, 1, [])) + self.assertIn("1 file failed.", screen_text(install.tui_view(state, 80, 24))) + press(state, "enter") + self.assertEqual(state.exit_code, 1) + + def test_put_back_and_delete_name_the_backup_alone(self): + folder = BACKUPS[0]["folder"] + for purpose, run, argv in ( + ("putback", "restore", [f"--restore={folder}", "--events"]), + ("dropbackup", "delete_backup", + [f"--delete-backup={folder}", "--events", "--yes"])): + with self.subTest(purpose=purpose): + state = backups_state(purpose) + press(state, "enter") + self.assertEqual(state.screen, "actconfirm") + self.assertEqual(press(state, "enter"), [("run", run)]) + self.assertEqual(install.tui_argv(state, run, "python", "install.py"), + ["python", "install.py", *argv]) + + def test_a_backup_looked_at_offers_both(self): + state = backups_state() + press(state, "down", "enter") + self.assertEqual(state.screen, "backup") + self.assertEqual(state.backup_chosen["date"], BACKUPS[1]["date"]) + self.assertEqual([row.action for row in install.tui_rows(state)], + ["bkrestore", "bkdelete", "back"]) + argv = install.tui_argv(state, "backups", "python", "install.py") + self.assertEqual(argv[-2:], ["--backups", "--events"]) + + def test_without_a_record_nothing_is_offered_to_remove(self): + state = checked_state() + state.record = None + state.stack += ["more", "remove"] + self.assertEqual([row.action for row in install.tui_rows(state)], ["back"]) + self.assertIn("No install record here", screen_text(install.tui_view(state, 80, 24))) + + def test_the_files_and_what_stays_are_on_details(self): + state = removal_state() + state.focus["removal"] = [row.action for row in install.tui_rows(state)].index( + "details") + press(state, "enter") + text = screen_text(install.tui_view(state, 80, 24)) + self.assertIn("pcsx/extra.bin", text) + self.assertIn("scph5501.bin: changed since it was installed", text) + + class ScreenWordBudgets(unittest.TestCase): def setUp(self): patcher = mock.patch.object(install, "_mounted_cards", return_value=[])