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 1/4] 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=[]) From 4e9a6974d34fe39f86d225e32e830b39a84f82e7 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 2/4] docs: describe removing files New section Removing files, the new options, the four event kinds and the exit codes of removal. --- wiki/installer.md | 89 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 6 deletions(-) diff --git a/wiki/installer.md b/wiki/installer.md index 180c7e7a8..05cf95281 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -797,6 +797,74 @@ beyond what it already used. A link sitting at a file's name is not kept: the file it points to is never touched. Data trees and `--standalone-copies` keep their replaced files the same way, beside the folder they were in. +## Removing files { #removing-files } + +`--remove` takes out of the BIOS folder what the installer put there, and only +that. The [install record](#the-install-record) says which files these are, so +a folder without one loses nothing: + +``` +python3 install.py --remove --system nintendo-gba +Remove 2 files from ~/.config/retroarch/system for RetroArch, 12.0 KB freed. +1 file stays: 1 still needed. + shared.bin: still needed by PlayStation +Remove them for good? [y/N] +``` + +Without a narrowing option every file of the setup's record goes. `--system`, +`--core` or `--emulator` takes one part, and `--file PATH` one file. The +question comes once, names what goes and the space freed, and only `y` or +`yes` goes on. `--dry-run` lists the files and removes nothing, and `--yes` +answers it for a script. A run without a terminal and without `--yes` stops +with code 2 and names `--yes`. + +Removal deletes for good. `--backup` moves the files into a new dated folder of +`retrobios-backup` instead, and the end of the run prints the `--restore` +command that puts them back. On one drive a move frees no space until the +backup is deleted. + +A file is removed only when all of these hold: + +- the record lists it for this setup and this folder +- its bytes are still the SHA-1 the record holds +- it is a regular file, below the BIOS folder, reached through no link +- the setup's current list still serves that SHA-1 at that place, so a later + install can put it back. + +Anything else stays and is named with its reason: a file changed since, a +link, a file outside the BIOS folder (the save directory's included), a file +the list no longer serves (a key the user supplied, a dump of another +release), and a file still needed by what stays. A file two systems share +stays while one of them is still chosen, and a file another setup of the same +folder recorded stays too. Removing everything also counts the files the +record never names, added by hand or by another program, and leaves them. +A record that cannot be read is said in one line, and nothing is removed. +On FAT and exFAT cards, which open a name whatever its case, names are +compared without case. + +The record is updated after the run: the files removed leave it, the systems +or cores removed leave its choice, and a setup with nothing left goes from +it. `--restore` on a removal's backup gives the files back to the record with +the choice they had. On a backup of files an install replaced, the record lets +go of each file put back: it is the user's again. + +`--backups` lists the backups of the BIOS folder, newest first, and +`--delete-backup FOLDER` deletes one after a question naming its date and +size. Only a dated folder holding `.retrobios-backup`, inside +`retrobios-backup`, is ever deleted, and no link inside it is followed. On the +full screen, **More** on the plan, shown once the folder has a record or a +backup, holds **Remove files**, **Backups**, **Put back a backup** and +**Delete a backup**. Remove files lists every recorded file or one system, +then the plan of the removal (what goes, the space freed, what stays and why, +the files behind **Details**), **Keep a backup**, and a confirm before +anything is removed. + +Exit codes: `0` once done, `1` when a file or the record could not be +written, `2` when nothing was done (no record, nothing to remove, no `--yes` +without a terminal, or no at the question). With `--events` the run writes +`removal_plan`, `removed`, `backup` and `restored` events, see +[Machine-readable events](#machine-readable-events). + ## Copy from an old card { #copy-from-an-old-card } The card a handheld came with, a backup of an old BIOS folder, a Downloads @@ -1111,7 +1179,12 @@ what the options name, everything by default. | `--from-pack PATH` | Install from a pack already downloaded: its ZIP, one of its parts (the others are found in the same folder), or the folder holding them. Makes no network request and checks every file's hash. See [Without a network](#without-a-network-offline-install) | | `--offline` | Make no network request. `--check`, `--from`, `--from-pack` and `--manifest` work without one, the file list stops the run at once when none of them gives it, and a file the run would download fails at once. `RETROBIOS_INSTALL_OFFLINE=1` does the same. `--target` and `--list-targets` read the core list per board a pack carries, or `targets/.json` beside `--manifest`, and are refused when neither gives one | | `--manifest FILE` | Read the file list from FILE, a copy of `install/.json`, instead of downloading it. See [Without a network](#without-a-network-offline-install) | -| `--restore FOLDER` | Put back the files a run replaced and kept in FOLDER, a dated folder inside `retrobios-backup`. Takes no other option but `--dry-run` and `--no-input`. See [Files already in the folder](#files-you-already-had) | +| `--restore FOLDER` | Put back the files a run replaced or a removal moved and kept in FOLDER, a dated folder inside `retrobios-backup`, and update the install record. Takes no other option but `--dry-run`, `--events`, `--yes`, `--no-input` and `--verbose`. See [Files already in the folder](#files-you-already-had) | +| `--remove` | Remove what the installer placed in the BIOS folder, as its [install record](#the-install-record) proves it: every file, or those of one `--system`, `--core`, `--emulator` or `--file`. Deleted for good unless `--backup` is given. See [Removing files](#removing-files) | +| `--file PATH` | With `--remove`, one file of the BIOS folder, by its path there or in full. Repeat for several | +| `--backup` | With `--remove`, move the files into a dated folder of `retrobios-backup` instead of deleting them, for `--restore` to put back | +| `--backups` | List the backups of the BIOS folder, newest first, with the date, the files and the size of each | +| `--delete-backup FOLDER` | Delete one backup listed by `--backups`, after one question that names its date and size (`--yes` skips it) | | `--tui` | Open the full-screen interface, or stop with the reason it cannot open here (code 2) instead of falling back to the questions. Wins over `RETROBIOS_PLAIN` and `ACCESSIBLE`. See [The full-screen interface](#the-full-screen-interface) | | `--plain` | Ask the questions line by line instead of the full-screen interface, the mode for a screen reader. See [The full-screen interface](#the-full-screen-interface) | | `--version` | Print the installer's identity, the first 12 hex digits of the SHA-256 the bootstraps pin | @@ -1891,9 +1964,9 @@ fields, the installer refuses to narrow rather than install everything. | Code | Meaning | |------|---------| -| `0` | everything asked for is installed or already in place | -| `1` | a failure: the one-liner could not download or verify `install.py`, or found no Python 3.8 with its ssl, unicodedata and zlib modules (nothing was run), an unusable base URL, `--installed-cores` with no core found, a manifest that fails validation, a fetch that never succeeded, no platform to install for in a non-interactive run, some files failed or were left out by `--offline` while others installed, a `--from-pack` pack that cannot be read, lacks a part or is for another platform, a `--from` folder that does not exist or cannot be read, a BIOS folder that is a file or lies under one, a `--dest` that is not the card of the system named (another system's card, a folder inside one, a drive with none of its files where its card always has them, or a folder that does not exist), `--restore` could not put a file back, or `--add-app` could not put every file of the app on the card | -| `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one), or `--offline` left every file out, the drive has too little free space for the download (checked before any folder is created), or the question about a folder that does not exist, named with `--dest` or typed at the folder question, was answered no. Also an option the installer refused before any download: an unknown option, `--jobs` outside 1 to 32, `--target` with `--every-board`, `--target` with `--from-pack` or `--offline` (or `RETROBIOS_INSTALL_OFFLINE` set) when no core list per board is at hand (the pack carries none and none is beside `--manifest` as `targets/.json`), or for a platform that publishes none, which is said to have no hardware targets, `--list-targets` with `--offline` and neither, `--add-app` with `--check`, `--dry-run`, a listing, `--from-pack` or `--offline`. The message then starts with `install.py: error:`. Also an unknown platform, target, system, core or emulator, said in an `Error:` or `Unknown platform` line and an `error` event whose `stage` is `option`. Also `--add-app` for a folder on no card that takes the app, said in an `Error:` line and an `app` event before anything is written. With `--restore`, a folder that is not a dated folder inside `retrobios-backup`. Also a quit chosen before the install, as `gh` ends a cancelled prompt: `q` at a question, `q` or **Quit** on the full screen, which then keeps `RetroBIOS: quit. Nothing was installed.`, and the full screen closed with no button pressed (`RETROBIOS_IDLE_EXIT`). No code but `0` closes a Windows Terminal tab started on the installer itself, so such a tab stays open on a quit | +| `0` | everything asked for is installed or already in place, or removed, put back or deleted | +| `1` | a failure: the one-liner could not download or verify `install.py`, or found no Python 3.8 with its ssl, unicodedata and zlib modules (nothing was run), an unusable base URL, `--installed-cores` with no core found, a manifest that fails validation, a fetch that never succeeded, no platform to install for in a non-interactive run, some files failed or were left out by `--offline` while others installed, a `--from-pack` pack that cannot be read, lacks a part or is for another platform, a `--from` folder that does not exist or cannot be read, a BIOS folder that is a file or lies under one, a `--dest` that is not the card of the system named (another system's card, a folder inside one, a drive with none of its files where its card always has them, or a folder that does not exist), `--restore` could not put a file back, `--remove` or `--delete-backup` could not delete a file or update the install record, or `--add-app` could not put every file of the app on the card | +| `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one), or `--offline` left every file out, the drive has too little free space for the download (checked before any folder is created), or the question about a folder that does not exist, named with `--dest` or typed at the folder question, was answered no. Also an option the installer refused before any download: an unknown option, `--jobs` outside 1 to 32, `--target` with `--every-board`, `--target` with `--from-pack` or `--offline` (or `RETROBIOS_INSTALL_OFFLINE` set) when no core list per board is at hand (the pack carries none and none is beside `--manifest` as `targets/.json`), or for a platform that publishes none, which is said to have no hardware targets, `--list-targets` with `--offline` and neither, `--add-app` with `--check`, `--dry-run`, a listing, `--from-pack` or `--offline`. The message then starts with `install.py: error:`. Also an unknown platform, target, system, core or emulator, said in an `Error:` or `Unknown platform` line and an `error` event whose `stage` is `option`. Also `--add-app` for a folder on no card that takes the app, said in an `Error:` line and an `app` event before anything is written. With `--restore` or `--delete-backup`, a folder that is not a dated folder inside `retrobios-backup`. With `--remove`, nothing removed: no install record of the setup, nothing it can remove, no `--yes` without a terminal, or no at its question, and the same for `--delete-backup`. Also a quit chosen before the install, as `gh` ends a cancelled prompt: `q` at a question, `q` or **Quit** on the full screen, which then keeps `RetroBIOS: quit. Nothing was installed.`, and the full screen closed with no button pressed (`RETROBIOS_IDLE_EXIT`). No code but `0` closes a Windows Terminal tab started on the installer itself, so such a tab stays open on a quit | | `3` | `--check` ran and found files to fetch or wrong copies, nothing having failed. A failure takes `1` as before: a manifest refused, a `--from` folder that does not exist. Until November 2026 this case also ended on `1`, so a script that reads any code but `0` as "not ready" needs no change | | `130` | stopped with Ctrl+C, or with Ctrl+C or Ctrl+D at a question, the terminal keeping `RetroBIOS: stopped. Nothing was installed.` before an install. Run the same command again to carry on | | `141` | the output was piped to a reader that closed early (`--list-platforms \| head`) | @@ -1937,7 +2010,7 @@ other schemas under `api/v1/schemas/`. | Kind | Keys | When | |------|------|------| -| `start` | `schema`, `installer`, `mode` | First line. `schema` is 1, `installer` the identity `--version` prints, `mode` one of `install`, `check`, `dry-run`, `list` | +| `start` | `schema`, `installer`, `mode` | First line. `schema` is 1, `installer` the identity `--version` prints, `mode` one of `install`, `check`, `dry-run`, `list` (also `--backups`), `remove` (`--remove`, `--delete-backup`), `restore` | | `unsearched` | `folder`, `reason`, `message` | Before `detect`, once for each folder the search for setups could not look in, this user being refused it or a read failing there (a failing card, a link that loops), or under WSL the Windows side when Windows did not answer (`folder` is then the drive mount, `/mnt`). What it would hold counts as absent, and the other places are still searched: `detect` then lists what they hold, and an empty `platforms` after an `unsearched` line means nothing was found where the installer could look. `reason` says why in words (`Permission denied`), `message` says it in a sentence | | `detect` | `os`, `os_name`, `platforms` | Once the platforms are known. `os_name` comes from `/etc/os-release`. `platforms` lists `{platform, name, bios_dir}`, and is empty when nothing supported was found | | `error` | `stage`, `reason`, `platform`, `message` | The file list could not be read (`stage` is `manifest`): `reason` is `network`, `not_found` or `invalid`, `clock` when the server's certificate was refused and the system clock reads a date before the installer was written, `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one), `no_ssl` when this Python was built without its `ssl` module and cannot use HTTPS, or `offline` when `--offline` forbids the request. A folder named with `--from` cannot be read (`stage` is `from`, `reason` `not_found`, `not_a_folder` for a file that is no ZIP, `unreadable`, or `system` for the root of the system, `/proc`, `/sys` or `/dev`). The BIOS folder sits under a folder this user cannot open (`stage` is `dest`, `reason` `unreadable`), or is a file or lies under one (`reason` `not_a_folder`), and the run ends with code 1 before anything is written. A platform, target, system, core or emulator named on the command line is unknown or cannot be used here (`stage` is `option`, `reason` `unknown`, `platform` the platform it was checked against or the name typed), refused before any download with code 2. The BIOS folder cannot be created (`stage` is `dest`, `reason` `not_writable`): a `summary` follows with what the run installed before it. Two systems are detected on one machine and no `--dest` names the folder (`stage` is `detect`, `reason` `conflict`). The platform named with `--platform` is found nowhere and no folder can be assumed for it: a card system with no card named, a portable emulator, Batocera or Recalbox named on a computer (their folder is the `bios` folder of their SHARE drive), or no usual folder on this system or one set aside behind a folder this user cannot open (`stage` is `detect`, `reason` `not_found`). So is a platform named by a file list or a pack, in a run without questions where it is found nowhere here. A `--dest` refused as the card of the system named carries the same `stage` and `reason`: another system's card, a folder inside a card, a drive with none of the files its card always has, or a folder that does not exist. `message` says it in words, and is the only thing that tells these cases apart | @@ -1966,11 +2039,15 @@ other schemas under `api/v1/schemas/`. | `summary` | `mode`, `installed`, `up_to_date`, `pending`, `failed`, `skipped`, `left_out`, `kept`, `interrupted` | The outcome. `pending` counts what a next run would still fetch, `failed` lists `{dest, reason}`, `skipped` lists `{reason, label, files, required}` for the files the collection cannot supply, `left_out` lists `{category, label, files, dests}` for what `--no-game-data` left out, and is empty without it. `kept` lists `{folder, files, bytes}`, one per backup folder, for the files in place the run moved aside before replacing them (with `--dry-run`, would move), and is empty when none. Not written for a listing | | `app` | `system`, `folder`, `ok`, `reason`, `dests`, `written`, `kept` | After `summary`, with `--add-app` or a yes to its question: the RetroBIOS app on the card of `system` whose root is `folder`. `dests` lists the app's files below that root, `written` the ones this run wrote (empty when each was in place), `kept` the backup folders as in `summary`. `ok` is false, with `reason`, when the card takes no app (before anything is written, code 2), when the install did not finish, or when a file could not be fetched, verified or written (code 1) | | `record` | `platform`, `folder`, `path`, `status`, `choice`, `date`, `files`, `message` | The [install record](#the-install-record) of `folder`, kept at `path`. `status` is `applied` before `choices` when the run took its choice, nothing on the command line having chosen, `written` before `summary` once an install placed files, `unreadable` when it cannot be read (said once, ignored), or `not_written` when it could not be saved, the install itself unharmed. `choice` is the recorded options as words, `date` when it was written (UTC), `files` how many files it holds for the platform | +| `removal_plan` | `platform`, `folder`, `files`, `bytes`, `systems`, `dests`, `kept`, `gone`, `backup`, `message` | With `--remove`, before anything is removed, and all a `--dry-run` writes: `dests` the files that go in record order, `bytes` the space they free, `systems` those losing a file, `kept` a `{dest, reason}` for each file left (`shared`, `changed`, `link`, `not_a_file`, `outside`, `not_servable`, `unreadable`, `not_recorded`), `gone` the recorded files no longer there, `backup` whether they are moved rather than deleted. With `files` 0 and a `message` when no record says what is the installer's | +| `removed` | `platform`, `dest`, `size`, `ok`, `reason`, `moved_to` | Each file `--remove` took. `ok` is false, with `reason`, for a file that stays. `moved_to` is its place in the backup with `--backup`, empty otherwise | +| `restored` | `folder`, `path`, `size`, `ok`, `reason` | Each file `--restore` put back from the backup `folder` to `path` | +| `backup` | `folder`, `date`, `files`, `bytes`, `source`, `status`, `message` | A backup folder: `status` is `listed` by `--backups`, `made` by `--remove --backup`, `deleted` or `not_deleted` by `--delete-backup`. `date` is when it was made, local time. `source` is `remove` for a removal's backup and `install` for the files an install replaced | | `end` | `code` | Last line, with the exit status the process returns (see [Exit status](#exit-status)) | -Schema 1 can gain a kind (`found` came with `--from`, `declared` with `--declared-only`, `unsearched` with the search that goes on past a closed folder, `app` with `--add-app`, `data` with the size of the data trees, `version` with the list's version, `record` with the install record), never a key of an +Schema 1 can gain a kind (`found` came with `--from`, `declared` with `--declared-only`, `unsearched` with the search that goes on past a closed folder, `app` with `--add-app`, `data` with the size of the data trees, `version` with the list's version, `record` with the install record, `removal_plan`, `removed`, `restored` and `backup` with removal), never a key of an existing kind: a reader skips a kind it does not know. An option refused before the run starts (`install.py: error:`, code 2) writes no event at all. A program reads a missing `end` as that case, or as a From 4bb76b67c2a15f4fb370b4c53922bc9442132497 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:06:57 +0100 Subject: [PATCH 3/4] fix: keep files a removal or restore would lose Restore moves a file in place with other bytes into a new dated backup, the installer's own recorded copy aside. Removal hashes each file again just before it goes and keeps one changed since the plan, keeps the record entries of files that stayed, touches the record only after the yes, and stops on a BIOS folder that is not there. A Windows junction counts as a link in the planner, the backup walk and --delete-backup. A place both the platform's list and a core name is never a core's alone, and two names of one file on a folding drive are planned once. A restore into a moved folder says the record was not updated. --- install.ps1 | 2 +- install.py | 217 ++++++++++++++++++++++++++-------- install.sh | 2 +- install/launchers/index.json | 16 +-- tests/test_install_removal.py | 178 ++++++++++++++++++++++++++++ wiki/installer.md | 25 ++-- 6 files changed, 374 insertions(+), 66 deletions(-) diff --git a/install.ps1 b/install.ps1 index 93d937a5a..93fff7d90 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 = "ce18b5e213fde35341231cd79a9c9205f57f3f096827dc50e26db9487d1b5349" + $defaultInstallSha256 = "e1460704fe40e4c91b9c410ae119ceb1ece3b375d9a3dc6be00a96d5f5d348bf" # 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 f466236bd..873ac0fa7 100644 --- a/install.py +++ b/install.py @@ -6548,6 +6548,39 @@ def apply_record(args: argparse.Namespace, plat: str, folder: Path) -> dict | No return setup +# Windows marks a directory junction, and any other reparse point, with this +# attribute. Before Python 3.12 lstat does not report a junction as a link, +# and os.walk(followlinks=False) descends into it. +_REPARSE_POINT = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) + + +def _is_link(info: os.stat_result) -> bool: + """Whether what lstat read is a link: a symbolic link, or on Windows a + junction or any other reparse point.""" + return stat.S_ISLNK(info.st_mode) or bool( + getattr(info, "st_file_attributes", 0) & _REPARSE_POINT) + + +def _tree(top: Path) -> list[tuple[Path, os.stat_result]]: + """Every entry below top with what lstat read of it, children before + their folder. A link (a junction too) is listed, never entered.""" + found: list[tuple[Path, os.stat_result]] = [] + try: + names = sorted(os.listdir(top)) + except OSError: + return found + for name in names: + path = top / name + try: + info = os.lstat(path) + except OSError: + continue + if stat.S_ISDIR(info.st_mode) and not _is_link(info): + found += _tree(path) + found.append((path, info)) + return found + + 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 @@ -6558,20 +6591,18 @@ def _is_backup_run(run: Path) -> bool: except OSError: return False return (stat.S_ISDIR(info.st_mode) and stat.S_ISDIR(holder.st_mode) + and not _is_link(info) and not _is_link(holder) and run.parent.name == BACKUP_DIR and stat.S_ISREG(marker.st_mode)) 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.""" + inside it, junctions included, are never followed nor put back.""" 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 in (BACKUP_MARKER, REMOVED_NOTE)) + path for path, info in _tree(run) + if stat.S_ISREG(info.st_mode) and not _is_link(info) + and not (path.name.startswith(".") and path.name.endswith(".part")) + and not (path.parent == run and path.name in (BACKUP_MARKER, REMOVED_NOTE)) ) @@ -6617,6 +6648,10 @@ def _record_after_restore(run: Path, put_back: list[Path]) -> str: path = _below(folder, item["dest"])[0] if path is not None and not _check_ours(path, item)[0]: back.append(item) + if not back and put_back: + # The files went back, but not where the record's folder is: the + # folder moved or was renamed since the removal. + return f"it is for {folder}, which does not hold the files put back" if not back: return "" return _update_record(folder, note["platform"], @@ -6637,6 +6672,33 @@ def _record_after_restore(run: Path, put_back: list[Path]) -> str: return next((problem for problem in problems if problem), "") +def _recorded_copy(run: Path, dest: Path) -> bool: + """Whether the file at dest is the installer's own copy, as the record + of its folder names it: the collection gives it again, so a restore + replaces it without keeping it.""" + found = _record_folder(run, dest) + if found is None: + return False + try: + info = os.lstat(dest) + except OSError: + return False + if not stat.S_ISREG(info.st_mode) or _is_link(info): + return False + folder, rel = found + items = [item for setup in ((load_record(folder).data or {}).get("setups") or {}).values() + for item in setup.get("files") or [] + if str(item.get("root") or "") in ("", "data") + and item["dest"].casefold() == rel.casefold() and item["size"] == info.st_size] + if not items: + return False + try: + sha1 = _digest_file(dest, ("sha1",))["sha1"] + except OSError: + return False + return any(item["sha1"] == sha1 for item in items) + + 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 @@ -6688,8 +6750,9 @@ def restore_kept(run: Path, dry_run: bool = False) -> int: 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: + parent. A file there with other contents is moved into a new dated + backup first, never lost. 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. """ @@ -6706,8 +6769,10 @@ def restore_kept(run: Path, dry_run: bool = False) -> int: return 0 restored = size = failed = 0 put_back: list[Path] = [] + KEPT.reset() for src in files: - dest = root / src.relative_to(run) + rel = PurePosixPath(*src.relative_to(run).parts) + dest = root / Path(*rel.parts) length = src.stat().st_size if dry_run: print(f" would put back {dest}") @@ -6715,11 +6780,20 @@ def restore_kept(run: Path, dry_run: bool = False) -> int: size += length continue try: + sha1 = _digest_file(src, ("sha1",))["sha1"] dest.parent.mkdir(parents=True, exist_ok=True) + # A file at the place that is not the backup's copy (a newer one, + # one the user put there) is moved aside first, into a new dated + # backup, as an install keeps what it replaces. Its first level + # is the folder the backup's holder sits in. + holder = (root / rel.parts[0], PurePosixPath(*rel.parts[1:])) \ + if len(rel.parts) > 1 else (root, rel) + if not _recorded_copy(run, dest): + KEPT.keep(*holder, dest, {"sha1": sha1}) _clear_read_only(dest) _replace_with_copy(src, dest) - except OSError as exc: - reason = exc.strerror or str(exc) + except (OSError, _NotKept) as exc: + reason = getattr(exc, "strerror", None) 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) @@ -6736,6 +6810,10 @@ def restore_kept(run: Path, dry_run: bool = False) -> int: return 0 problem = _record_after_restore(run, put_back) print(f"\nPut back {_plural(restored, 'file')} ({format_size(size)}) under {root}.") + for item in KEPT.summary(): + # The files that sat at those places, never lost. + print(f"Kept {_plural(item['files'], 'file')} that were in their place " + f"({format_size(item['bytes'])}) in {item['folder']}") if failed: print(f"{_plural(failed, 'file')} could not be put back.", file=sys.stderr) if problem: @@ -6759,6 +6837,8 @@ REMOVAL_REASONS = { "unreadable": "could not be read", "not_recorded": "not installed by RetroBIOS", } +# Why a planned file stays when it changed between the plan and its removal. +CHANGED_SINCE_PLAN = "changed since the plan" # 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" @@ -6797,22 +6877,24 @@ def _entry_readers(entry: dict) -> set[str] | None: 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.""" + its systems, the cores that read it, whether the platform's own list + names it). A place listed twice keeps both: a core's copy beside the + platform's own never makes the file a core's alone. 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)) + sha1s, systems, readers, own = served.get(place, (set(), [], set(), False)) 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())) + readers | (mine or set()), own or mine is None) 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) + {str(meta.get("sha1"))}, list(tree.get("systems") or []), set(), True) return served @@ -6833,7 +6915,7 @@ def _below(folder: Path, dest: str) -> tuple[Path | None, str]: return None, "gone" except OSError: return None, "unreadable" - if stat.S_ISLNK(info.st_mode): + if _is_link(info): return None, "link" if not stat.S_ISDIR(info.st_mode): return None, "not_a_file" @@ -6849,7 +6931,7 @@ def _check_ours(path: Path, item: dict) -> tuple[str, os.stat_result | None]: return "gone", None except OSError: return "unreadable", None - if stat.S_ISLNK(info.st_mode): + if _is_link(info): return "link", info if not stat.S_ISREG(info.st_mode): return "not_a_file", info @@ -6925,16 +7007,23 @@ def plan_removal( else set(known["cores"]) | set(manifest.get("core_names") or {}) ) - chosen_cores remove, kept, gone, picked = [], [], [], set() + planned: set[tuple[str, str]] = 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)) + sha1s, item_systems, readers, own = served.get( + (root, norm(dest)), (set(), [], set(), False)) 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): + # The platform's own list keeps its files whatever core is removed. + if chosen_cores and (own or not readers & chosen_cores): continue if files and norm(dest) not in wanted: continue + if (root, norm(dest)) in planned: + # Two entries the drive opens as one file: planned once. + continue + planned.add((root, norm(dest))) picked.add(norm(dest)) entry = {"dest": dest, "root": root, "sha1": item["sha1"], "size": item["size"], "systems": item_systems} @@ -6961,7 +7050,7 @@ def plan_removal( 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)}) + remove.append({**entry, "path": path, "inode": _stamp(info)}) kept += [{"dest": dest, "root": "", "sha1": "", "size": 0, "systems": [], "reason": "not_recorded", "what": ""} for dest in files if norm(dest) not in picked] @@ -7019,12 +7108,22 @@ def kept_line(item: dict) -> str: return f"{item['dest']}: {why}" +def _stamp(info: os.stat_result) -> tuple[int, int, int, int]: + """What tells a file from itself edited: device, inode, size, mtime.""" + return info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns + + +class _ChangedSincePlan(OSError): + """The file is not the one the plan read: it stays.""" + + 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.""" + backup folder: (removed, failed {dest, reason, changed}, the backup + folder). Each file is read again just before, and hashed again: one + edited in place during the question, swapped, or turned into a link + stays, as changed since the plan.""" removed, failed = [], [] run: Path | None = None under: Path | None = None @@ -7033,9 +7132,10 @@ def remove_files(removal: Removal, setup: dict, backup: bool 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 (not stat.S_ISREG(info.st_mode) or _is_link(info) + or _stamp(info) != entry["inode"] + or _digest_file(path, ("sha1",))["sha1"] != entry["sha1"]): + raise _ChangedSincePlan(errno.ESTALE, CHANGED_SINCE_PLAN) if backup: if run is None or under is None: run, under = KEPT.place(removal.folder, create=True) @@ -7048,7 +7148,8 @@ def remove_files(removal: Removal, setup: dict, backup: bool path.unlink() except OSError as exc: reason = exc.strerror or str(exc) - failed.append({"dest": entry["dest"], "reason": reason}) + failed.append({"dest": entry["dest"], "reason": reason, + "changed": isinstance(exc, _ChangedSincePlan)}) EVENTS.emit("removed", platform=removal.plat, dest=entry["dest"], size=entry["size"], ok=False, reason=reason, moved_to="") continue @@ -7071,7 +7172,7 @@ def _write_removed_note(run: Path, removal: Removal, setup: dict) -> None: 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.""" + choice left: the setup goes once it holds no file.""" taken = {(entry["root"], entry["dest"]) for entry in removed} | { (str(item.get("root") or ""), item["dest"]) for item in removal.gone} @@ -7080,9 +7181,13 @@ def _record_after_removal(removal: Removal, removed: list[dict]) -> str: 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: + if not files: return None - return {**setup, "written": _record_now(), "choice": list(removal.choice), + # A file that stayed (it failed, changed, or something still needs + # it) keeps its entry. With nothing left chosen, the choice stays the + # one recorded: no choice would read as the whole list. + choice = setup.get("choice") if removal.choice is None else removal.choice + return {**setup, "written": _record_now(), "choice": list(choice or []), "files": files} return _update_record(removal.folder, removal.plat, change) @@ -7114,6 +7219,15 @@ def run_removal(args: argparse.Namespace) -> int: 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) + if not os.path.isdir(folder): + # An unmounted card looks like every file gone: nothing is touched. + message = (f"Nothing was removed: {shown_path(folder)} is not there (a card " + "not mounted?).") + 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 read = load_record(folder) setup, _path, problem = setup_record(plat, folder) if setup is None or read.data is None: @@ -7181,8 +7295,7 @@ def run_removal(args: argparse.Namespace) -> int: for rel in by_hand: print(f" {rel}") if not removal.remove: - if removal.gone and not args.dry_run: - _record_after_removal(removal, []) + # The record keeps what it says until a removal is confirmed. return 2 if args.dry_run: print("Nothing was removed (--dry-run).") @@ -7211,6 +7324,13 @@ def run_removal(args: argparse.Namespace) -> int: message=f"Moved {_plural(len(removed), 'file')} here.") elif removed: print(f"Removed {_plural(len(removed), 'file')}, {format_size(freed)} freed.") + changed = [item for item in failed if item["changed"]] + if changed: + print(f"{agree(len(changed), 'file stays', 'files stay')}: " + f"{len(changed)} {CHANGED_SINCE_PLAN}.") + for item in changed: + print(f" {item['dest']}: {CHANGED_SINCE_PLAN}") + failed = [item for item in failed if not item["changed"]] for item in failed: print(f" failed: {item['dest']} ({item['reason']})", file=sys.stderr) if problem: @@ -7316,19 +7436,18 @@ def run_delete_backup(args: argparse.Namespace) -> int: 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) + # Bottom up, never through a link: a link goes, not what it names. A + # junction is removed as a folder, which leaves its target alone. + for path, info in _tree(run): + try: + if stat.S_ISDIR(info.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: @@ -25754,7 +25873,7 @@ def tui_removal_systems(state: TuiState) -> list[tuple[str, int, int]]: 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] + (set(), [], set(), False))[1] for system in systems or [OTHER_SYSTEMS]: tally = held.setdefault(system, [0, 0]) tally[0] += 1 diff --git a/install.sh b/install.sh index fbcfeff31..47dcfa96b 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="ce18b5e213fde35341231cd79a9c9205f57f3f096827dc50e26db9487d1b5349" +DEFAULT_INSTALL_SHA256="e1460704fe40e4c91b9c410ae119ceb1ece3b375d9a3dc6be00a96d5f5d348bf" # 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 cf4950b2f..583697cdb 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": 1280415, - "sha256": "ce18b5e213fde35341231cd79a9c9205f57f3f096827dc50e26db9487d1b5349", + "size": 1285922, + "sha256": "e1460704fe40e4c91b9c410ae119ceb1ece3b375d9a3dc6be00a96d5f5d348bf", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1334040, - "sha256": "5f526e2d068764c5a178eccaf82f97012ac8bda61a0db251501580a8efd698eb" + "size": 1339547, + "sha256": "974a225380c719ecee6dbc226ed17d1709ed406d08556657c15cccebb1eb587c" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1280415, - "sha256": "ce18b5e213fde35341231cd79a9c9205f57f3f096827dc50e26db9487d1b5349", + "size": 1285922, + "sha256": "e1460704fe40e4c91b9c410ae119ceb1ece3b375d9a3dc6be00a96d5f5d348bf", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1280415, - "sha256": "ce18b5e213fde35341231cd79a9c9205f57f3f096827dc50e26db9487d1b5349", + "size": 1285922, + "sha256": "e1460704fe40e4c91b9c410ae119ceb1ece3b375d9a3dc6be00a96d5f5d348bf", "executable": false }, { diff --git a/tests/test_install_removal.py b/tests/test_install_removal.py index 56c454f30..e14a1a9fd 100644 --- a/tests/test_install_removal.py +++ b/tests/test_install_removal.py @@ -20,6 +20,7 @@ import shutil import subprocess import sys import tempfile +import types import unittest from pathlib import Path from unittest import mock @@ -193,6 +194,9 @@ class NeverTouched(unittest.TestCase): 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) + # The file that stayed keeps its entry: the setup is not dropped. + self.assertEqual([item["dest"] for item in repo.setup()["files"]], + ["scph5501.bin"]) def test_a_link_in_place_of_a_file_stays_and_its_target_too(self): with SharedRepository() as repo: @@ -466,5 +470,179 @@ class RestoreAnInstallBackup(unittest.TestCase): self.assertNotIn("gba_bios.bin", [item["dest"] for item in repo.setup()["files"]]) +class ReviewOfTheRemoval(unittest.TestCase): + """What a review of the first version found, each held by a test.""" + + 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() + + def test_restore_keeps_the_file_in_place_aside(self): + with SharedRepository() as repo: + repo.install_both() + proc = repo.run("--remove", "--system", "nintendo-gba", "--backup", "--yes") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + [backup] = install.list_backups(repo.dest) + (repo.dest / "gba_bios.bin").write_bytes(b"a newer one the user put") + 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"gba bios") + self.assertIn("Kept 1 file that were in their place", proc.stdout) + kept = [item for item in install.list_backups(repo.dest) + if item["folder"] != backup["folder"]] + self.assertEqual([item["files"] for item in kept], [1]) + [saved] = [path for path in kept[0]["folder"].rglob("gba_bios.bin")] + self.assertEqual(saved.read_bytes(), b"a newer one the user put") + # The same bytes in place: nothing more is kept. + again = repo.run("--restore", str(backup["folder"])) + self.assertEqual(again.returncode, 0, again.stdout + again.stderr) + self.assertNotIn("Kept", again.stdout) + + def _plan(self, payload: bytes, manifest: dict | None = None) -> tuple: + (self.folder / "a.bin").write_bytes(payload) + record = _record(self.folder, [_item("a.bin", payload)]) + return record, install.plan_removal( + "retroarch", self.folder, record, manifest or _served(("a.bin", payload))) + + def test_a_file_edited_in_place_after_the_plan_stays(self): + record, removal = self._plan(b"ours") + path = self.folder / "a.bin" + before = os.stat(path) + with open(path, "r+b") as handle: + handle.write(b"user") + # The same inode, size and even time: only its bytes tell. + os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns)) + removed, failed, _run = install.remove_files(removal, record["setups"]["retroarch"], + False) + self.assertEqual(removed, []) + self.assertEqual([(item["reason"], item["changed"]) for item in failed], + [(install.CHANGED_SINCE_PLAN, True)]) + self.assertEqual(path.read_bytes(), b"user") + + def test_a_file_that_failed_keeps_its_entry(self): + with SharedRepository() as repo: + repo.install_both() + if hasattr(os, "geteuid") and os.geteuid() == 0: + self.skipTest("root deletes anywhere") + (repo.dest / "pcsx").chmod(0o555) + try: + proc = repo.run("--remove", "--yes") + finally: + (repo.dest / "pcsx").chmod(0o755) + self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr) + self.assertEqual([item["dest"] for item in repo.setup()["files"]], + ["pcsx/extra.bin"]) + + def test_an_unmounted_folder_or_a_refusal_leaves_the_record(self): + with SharedRepository() as repo: + repo.install_both() + record = repo.record.read_bytes() + for path in repo.dest.rglob("*"): + if path.is_file(): + path.unlink() + asked = repo.run("--remove") + self.assertEqual(asked.returncode, 2) + self.assertEqual(repo.record.read_bytes(), record) + nothing = repo.run("--remove", "--system", "nintendo-gba", "--yes") + self.assertEqual(nothing.returncode, 2) + self.assertEqual(repo.record.read_bytes(), record) + shutil.rmtree(repo.dest) + gone = repo.run("--remove", "--yes") + self.assertEqual(gone.returncode, 2) + self.assertEqual(gone.stderr.strip().count("\n"), 0, gone.stderr) + self.assertIn("is not there", gone.stderr) + self.assertEqual(repo.record.read_bytes(), record) + + def junction(self, path: Path): + """lstat as Windows reads a directory junction at path, which Python + before 3.12 does not report as a link.""" + real = os.lstat + + def lstat(target, *args, **kwargs): + info = real(target, *args, **kwargs) + if Path(target) != path: + return info + return types.SimpleNamespace(**{name: getattr(info, name) for name in ( + "st_mode", "st_ino", "st_dev", "st_size", "st_mtime_ns", "st_uid")}, + st_file_attributes=0x400) + + return mock.patch.object(install.os, "lstat", side_effect=lstat) + + def test_a_junction_is_a_link(self): + (self.folder / "linked").mkdir() + payload = b"elsewhere" + (self.folder / "linked" / "deep.bin").write_bytes(payload) + record = _record(self.folder, [_item("linked/deep.bin", payload)]) + with self.junction(self.folder / "linked"): + removal = install.plan_removal("retroarch", self.folder, record, + _served(("linked/deep.bin", payload))) + self.assertEqual([item["reason"] for item in removal.kept], ["link"]) + + def test_a_backup_holding_a_junction_is_never_entered(self): + holder = self.root / install.BACKUP_DIR + run = holder / "20261103-120000" + (run / "bios").mkdir(parents=True) + (run / install.BACKUP_MARKER).write_text("marker") + (run / "bios" / "x.bin").write_bytes(b"kept") + junction = run / "bios" / "elsewhere" + junction.mkdir() + (junction / "precious.bin").write_bytes(b"not the backup's") + with self.junction(junction): + self.assertEqual(install._backup_files(run), [run / "bios" / "x.bin"]) + args = types.SimpleNamespace(delete_backup=run, dry_run=False, yes=True, + no_input=True) + with contextlib.redirect_stdout(io.StringIO()), \ + contextlib.redirect_stderr(io.StringIO()): + install.run_delete_backup(args) + self.assertEqual((junction / "precious.bin").read_bytes(), b"not the backup's") + self.assertFalse((run / "bios" / "x.bin").exists()) + + def test_a_core_never_takes_a_file_the_platform_lists_too(self): + payload = b"bios" + manifest = {"files": [ + {"dest": "a.bin", "sha1": hashlib.sha1(payload).hexdigest(), "size": 4, + "cores": ["mgba"], "systems": ["nintendo-gba"]}, + {"dest": "a.bin", "sha1": hashlib.sha1(payload).hexdigest(), "size": 4, + "cores": None, "systems": ["nintendo-gba"]}]} + (self.folder / "a.bin").write_bytes(payload) + record = _record(self.folder, [_item("a.bin", payload)]) + removal = install.plan_removal("retroarch", self.folder, record, manifest, + cores=["mgba"]) + self.assertEqual(removal.remove, []) + self.assertTrue((self.folder / "a.bin").exists()) + + def test_two_names_one_file_are_planned_once(self): + payload = b"bios" + (self.folder / "A.BIN").write_bytes(payload) + record = _record(self.folder, [_item("A.BIN", payload), _item("a.bin", payload)]) + # The drive opens a.bin as A.BIN, as FAT does. + found = ("", os.lstat(self.folder / "A.BIN")) + with mock.patch.object(install, "keeps_case", return_value=False), \ + mock.patch.object(install, "_check_ours", return_value=found): + removal = install.plan_removal("retroarch", self.folder, record, + _served(("a.bin", payload))) + self.assertEqual([entry["dest"] for entry in removal.remove], ["A.BIN"]) + + def test_a_restore_into_a_moved_folder_says_the_record_was_not_updated(self): + with SharedRepository() as repo: + repo.install_both() + repo.run("--remove", "--system", "nintendo-gba", "--backup", "--yes") + [backup] = install.list_backups(repo.dest) + moved = repo.root.with_name(repo.root.name + "-moved") + repo.root.rename(moved) + try: + run = moved / install.BACKUP_DIR / backup["folder"].name + with contextlib.redirect_stdout(io.StringIO()), \ + contextlib.redirect_stderr(io.StringIO()) as err: + code = install.restore_kept(run) + self.assertEqual(code, 1) + self.assertIn("The install record could not be updated", err.getvalue()) + self.assertTrue((moved / "dest" / "gba_bios.bin").is_file()) + finally: + moved.rename(repo.root) + + if __name__ == "__main__": unittest.main() diff --git a/wiki/installer.md b/wiki/installer.md index 05cf95281..0db0fdb6d 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -771,7 +771,12 @@ Kept 1 file already there (512.0 KB), which the collection's copies replaced, in ``` `--restore FOLDER` copies every file of that folder back to its place and -leaves the folder as it is, to delete once the emulators start as before. +leaves the folder as it is, to delete once the emulators start as before. A +file at that place with other contents (a newer one, one put there by hand) is +first moved into a new dated backup, as an install keeps what it replaces, and +the end of the run names that folder. The installer's own copy, as the +install record names it, is replaced without one: the collection gives it +again. Copying the contents of the dated folder into the folder that holds `retrobios-backup` does the same by hand. `--dry-run --restore FOLDER` lists what would go back. `--dry-run` alone counts the files an install would keep. @@ -838,20 +843,26 @@ release), and a file still needed by what stays. A file two systems share stays while one of them is still chosen, and a file another setup of the same folder recorded stays too. Removing everything also counts the files the record never names, added by hand or by another program, and leaves them. -A record that cannot be read is said in one line, and nothing is removed. +A record that cannot be read is said in one line, and nothing is removed. So +is a BIOS folder that is not there (a card not mounted), with code 2. Each +file is read and hashed again just before it goes: one edited during the +question stays, as changed since the plan. On FAT and exFAT cards, which open a name whatever its case, names are compared without case. -The record is updated after the run: the files removed leave it, the systems -or cores removed leave its choice, and a setup with nothing left goes from -it. `--restore` on a removal's backup gives the files back to the record with -the choice they had. On a backup of files an install replaced, the record lets +The record is updated only after the yes, and only for what was done: the +files removed leave it, the systems or cores removed leave its choice, a file +that stayed (changed, failed, still needed) keeps its entry, and a setup with +no file left goes from it. `--restore` on a removal's backup gives the files back to the record with +the choice they had. A folder moved since the removal is said, the record +left as it is. On a backup of files an install replaced, the record lets go of each file put back: it is the user's again. `--backups` lists the backups of the BIOS folder, newest first, and `--delete-backup FOLDER` deletes one after a question naming its date and size. Only a dated folder holding `.retrobios-backup`, inside -`retrobios-backup`, is ever deleted, and no link inside it is followed. On the +`retrobios-backup`, is ever deleted, and no link inside it is followed, a +Windows junction included. On the full screen, **More** on the plan, shown once the folder has a record or a backup, holds **Remove files**, **Backups**, **Put back a backup** and **Delete a backup**. Remove files lists every recorded file or one system, From a51f3d7f439ce873f955fafb0e71c1d4918ae8cf Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:34:48 +0100 Subject: [PATCH 4/4] fix: name the removal options on the site The options glance gains a Remove and put back group, the testing guide names test_install_removal, install.py declares _REPARSE_POINT once, and the type baseline drops three errors this change resolved. --- install.ps1 | 2 +- install.py | 6 ++---- install.sh | 2 +- install/launchers/index.json | 16 ++++++++-------- mypy-baseline.txt | 3 --- scripts/sitediagrams.py | 4 +++- wiki/testing-guide.md | 2 +- 7 files changed, 16 insertions(+), 19 deletions(-) diff --git a/install.ps1 b/install.ps1 index 93fff7d90..00d2dc7d4 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 = "e1460704fe40e4c91b9c410ae119ceb1ece3b375d9a3dc6be00a96d5f5d348bf" + $defaultInstallSha256 = "000880ee7a1293a5a3bc868b6bf7f3a4f262b61c485564cc11b433908acca817" # 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 873ac0fa7..f01e2d8cc 100644 --- a/install.py +++ b/install.py @@ -6548,10 +6548,6 @@ def apply_record(args: argparse.Namespace, plat: str, folder: Path) -> dict | No return setup -# Windows marks a directory junction, and any other reparse point, with this -# attribute. Before Python 3.12 lstat does not report a junction as a link, -# and os.walk(followlinks=False) descends into it. -_REPARSE_POINT = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) def _is_link(info: os.stat_result) -> bool: @@ -13878,6 +13874,8 @@ APPLE_DOUBLE = "._" # Folders the kernel makes up on the fly: endless, and never a copy. PSEUDO_FOLDERS = frozenset() if os.name == "nt" else frozenset(("/proc", "/sys", "/dev")) # FILE_ATTRIBUTE_REPARSE_POINT: a junction, a mount point or a link on Windows. +# Before Python 3.12 lstat does not report a junction as a link, and +# os.walk(followlinks=False) descends into it. _REPARSE_POINT = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) # Errors a member of someone's ZIP can raise on reading: damaged, encrypted # (RuntimeError), or packed with a method zipfile lacks. diff --git a/install.sh b/install.sh index 47dcfa96b..0f89cf441 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="e1460704fe40e4c91b9c410ae119ceb1ece3b375d9a3dc6be00a96d5f5d348bf" +DEFAULT_INSTALL_SHA256="000880ee7a1293a5a3bc868b6bf7f3a4f262b61c485564cc11b433908acca817" # 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 583697cdb..570c033de 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": 1285922, - "sha256": "e1460704fe40e4c91b9c410ae119ceb1ece3b375d9a3dc6be00a96d5f5d348bf", + "size": 1285764, + "sha256": "000880ee7a1293a5a3bc868b6bf7f3a4f262b61c485564cc11b433908acca817", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1339547, - "sha256": "974a225380c719ecee6dbc226ed17d1709ed406d08556657c15cccebb1eb587c" + "size": 1339389, + "sha256": "e305f03c65adbf3cf4eab08eaad2091bc52afa397c81895679d6172ca12ba061" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1285922, - "sha256": "e1460704fe40e4c91b9c410ae119ceb1ece3b375d9a3dc6be00a96d5f5d348bf", + "size": 1285764, + "sha256": "000880ee7a1293a5a3bc868b6bf7f3a4f262b61c485564cc11b433908acca817", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1285922, - "sha256": "e1460704fe40e4c91b9c410ae119ceb1ece3b375d9a3dc6be00a96d5f5d348bf", + "size": 1285764, + "sha256": "000880ee7a1293a5a3bc868b6bf7f3a4f262b61c485564cc11b433908acca817", "executable": false }, { diff --git a/mypy-baseline.txt b/mypy-baseline.txt index 4814cbb7d..f7b159c43 100644 --- a/mypy-baseline.txt +++ b/mypy-baseline.txt @@ -8,8 +8,6 @@ install.py:0: error: "object" has no attribute "get" [attr-defined] install.py:0: error: "object" has no attribute "get" [attr-defined] install.py:0: error: "object" object is not iterable [misc] install.py:0: error: "object" object is not iterable [misc] -install.py:0: error: "str" has no attribute "append" [attr-defined] -install.py:0: error: "str" has no attribute "append" [attr-defined] install.py:0: error: Argument 1 to "_ZippedLookup" has incompatible type "Optional[FolderIndex]"; expected "FolderIndex" [arg-type] install.py:0: error: Argument 1 to "__iadd__" of "list" has incompatible type "Invocation"; expected "Iterable[str]" [arg-type] install.py:0: error: Argument 1 to "_digest_file" has incompatible type "Optional[Path]"; expected "Path" [arg-type] @@ -118,7 +116,6 @@ install.py:0: error: Incompatible types in assignment (expression has type "int" install.py:0: error: Incompatible types in assignment (expression has type "int", variable has type "str") [assignment] install.py:0: error: Incompatible types in assignment (expression has type "list[dict[Any, Any]]", variable has type "int") [assignment] install.py:0: error: Incompatible types in assignment (expression has type "list[str]", variable has type "Invocation") [assignment] -install.py:0: error: Incompatible types in assignment (expression has type "list[str]", variable has type "str") [assignment] install.py:0: error: Incompatible types in assignment (expression has type "set[int]", variable has type "int") [assignment] install.py:0: error: Incompatible types in assignment (expression has type "set[int]", variable has type "list[dict[Any, Any]]") [assignment] install.py:0: error: Incompatible types in assignment (expression has type "set[int]", variable has type "list[dict[Any, Any]]") [assignment] diff --git a/scripts/sitediagrams.py b/scripts/sitediagrams.py index 09e78ddfd..c62fd7017 100644 --- a/scripts/sitediagrams.py +++ b/scripts/sitediagrams.py @@ -904,7 +904,9 @@ GLANCE_GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = ( "--cores-dir")), ("Look first", ("--check", "--dry-run", "--list-platforms", "--list-targets", "--list-systems", "--list-cores", "--list-emulators")), - ("From files on hand", ("--from", "--from-pack", "--manifest", "--offline", "--restore")), + ("From files on hand", ("--from", "--from-pack", "--manifest", "--offline")), + ("Remove and put back", ("--remove", "--file", "--backup", "--backups", "--restore", + "--delete-backup")), ("How the run talks", ("--tui", "--plain", "--no-input", "--yes", "--quiet", "--verbose", "--events", "--jobs", "--version", "--help")), ) diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index 2c1f6a867..a31eb1127 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -59,7 +59,7 @@ Every module in `tests/`, by the area it guards. | Packs and manifests | synthetic + real packs | `test_artifact_lock`, `test_atomic_artifacts`, `test_batocera_cores`, `test_core_data_trees`, `test_deterministic_zip`, `test_emulator_pack_counts`, `test_finalize_pack_names`, `test_large_file_cache`, `test_libretro_layout`, `test_manifest_entry`, `test_minui_platform`, `test_no_game_data`, `test_pack_conformance_dirs`, `test_pack_core_extras`, `test_pack_counts`, `test_pack_destinations`, `test_pack_failures`, `test_pack_file_names`, `test_pack_grouping`, `test_pack_integrity`, `test_pack_member_identity`, `test_pack_readme`, `test_platform_inheritance`, `test_platform_keys`, `test_release_packs`, `test_required_only_archives`, `test_required_only_twins`, `test_save_root`, `test_split_extras`, `test_split_names`, `test_split_pack`, `test_system_pack_extras`, `test_system_pack_parity`, `test_torrentzip`, `test_which_pack` | | Slots and regions | synthetic + profiles | `test_region`, `test_region_agnostic_scan`, `test_region_groups`, `test_region_held`, `test_region_platform_index`, `test_region_required_only`, `test_slot`, `test_slots`, `test_slots_shipped_file` | | Hardware targets and systems | synthetic + target files | `test_amberelec_targets`, `test_emuelec_targets`, `test_extract_targets`, `test_lakka_targets`, `test_libretro_slugs`, `test_system_ids`, `test_system_selection`, `test_target_names`, `test_target_scrapers`, `test_target_systems`, `test_target_verify` | -| Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_filesystems`, `test_install_app`, `test_install_from`, `test_install_games`, `test_install_journeys`, `test_install_offline`, `test_install_selection`, `test_install_tui`, `test_pypi_package` | +| Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_filesystems`, `test_install_app`, `test_install_from`, `test_install_games`, `test_install_journeys`, `test_install_offline`, `test_install_removal`, `test_install_selection`, `test_install_tui`, `test_pypi_package` | | Handheld launchers | a pty as the console, fake installer and firmware tools, stubbed PortMaster dialogs | `test_handheld_launchers`, `test_port` | | Running cores | parsers on fixed inputs, a libretro core built from C in the test when a compiler exists | `test_core_probe` | | Measurement records | synthetic record files, the committed `measurements/` against HEAD, the **Proof** line naming the BIOS a core ran, and that no page shows a Tested block, a region outcome or a time per frame | `test_measurements` |