diff --git a/install.ps1 b/install.ps1 index 7cdc38751..72ba6ca9e 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 = "8c8ead297c502f40f02f4407bdbb3edcb2af31b68f0a3fca06ed963c6cbc3cb0" + $defaultInstallSha256 = "300f5f9f82f21af68582b1cbd6cd1512883a2ce7fc9185dd04537b91f11b5f48" # 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 42aca769c..ddab26b16 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: @@ -6437,6 +6462,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: @@ -6462,8 +6496,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: @@ -6557,63 +6591,957 @@ def apply_record(args: argparse.Namespace, plat: str, folder: Path) -> dict | No return setup + + +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 + 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 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, junctions included, are never followed nor put back.""" + return sorted( + 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)) + ) + + +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 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"], + 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 _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 + 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. 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. + 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. """ 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()): + 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 the " - "end of the install printed it.", 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 - # Links inside the backup are never followed: os.walk does not descend - # into a linked folder and a linked file is skipped. - files = 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) - ) + 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] = [] + 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}") restored += 1 - size += src.stat().st_size + 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: - print(f" failed: {dest} ({exc.strerror or exc})", file=sys.stderr) + 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) 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}.") + 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) - 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", +} +# 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" + + +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, 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, own = served.get(place, (set(), [], set(), False)) + mine = _entry_readers(entry) + served[place] = (sha1s | _entry_sha1s(entry), + sorted(set(systems) | set(entry_systems(entry))), + 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 []), set(), True) + 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 _is_link(info): + 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 _is_link(info): + 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() + 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, 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 + # 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} + 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": _stamp(info)}) + 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 _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, 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 + 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 _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) + _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, + "changed": isinstance(exc, _ChangedSincePlan)}) + 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 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} + + 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 not files: + return None + # 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) + + +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) + 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: + 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: + # The record keeps what it says until a removal is confirmed. + 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.") + 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: + 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. 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: + 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( @@ -12989,6 +13917,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. @@ -14731,6 +15661,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", @@ -14892,23 +15856,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: @@ -14944,6 +15896,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 @@ -14990,6 +15945,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: @@ -18688,6 +19709,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: @@ -19175,6 +20211,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: @@ -19474,6 +20512,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) @@ -19660,10 +20700,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. @@ -19720,7 +20762,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. @@ -20221,6 +21274,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. @@ -20949,6 +22005,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")) @@ -21086,7 +22144,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) @@ -22494,6 +23552,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 @@ -22647,6 +23707,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) @@ -22664,6 +23726,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) @@ -22800,7 +23863,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", } @@ -24791,6 +25857,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) @@ -24810,6 +25878,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(), [], set(), False))[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, @@ -24819,7 +26224,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 c2c7b235a..706324eed 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="8c8ead297c502f40f02f4407bdbb3edcb2af31b68f0a3fca06ed963c6cbc3cb0" +DEFAULT_INSTALL_SHA256="300f5f9f82f21af68582b1cbd6cd1512883a2ce7fc9185dd04537b91f11b5f48" # 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 5519378ea..b0de514d8 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": 1222786, - "sha256": "8c8ead297c502f40f02f4407bdbb3edcb2af31b68f0a3fca06ed963c6cbc3cb0", + "size": 1287576, + "sha256": "300f5f9f82f21af68582b1cbd6cd1512883a2ce7fc9185dd04537b91f11b5f48", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1276411, - "sha256": "8008566a12fc1d18a19d9510c1884919d60ede9eb227bef25973082ac1b204e3" + "size": 1341201, + "sha256": "e03a69b28579955aac4970ece642996c5d81dead48fcbd7fef363b5179ba99a7" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1222786, - "sha256": "8c8ead297c502f40f02f4407bdbb3edcb2af31b68f0a3fca06ed963c6cbc3cb0", + "size": 1287576, + "sha256": "300f5f9f82f21af68582b1cbd6cd1512883a2ce7fc9185dd04537b91f11b5f48", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1222786, - "sha256": "8c8ead297c502f40f02f4407bdbb3edcb2af31b68f0a3fca06ed963c6cbc3cb0", + "size": 1287576, + "sha256": "300f5f9f82f21af68582b1cbd6cd1512883a2ce7fc9185dd04537b91f11b5f48", "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/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/scripts/sitediagrams.py b/scripts/sitediagrams.py index aa192d101..934f9c556 100644 --- a/scripts/sitediagrams.py +++ b/scripts/sitediagrams.py @@ -1002,7 +1002,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/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..e14a1a9fd --- /dev/null +++ b/tests/test_install_removal.py @@ -0,0 +1,648 @@ +"""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 types +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) + # 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: + 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"]]) + + +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/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=[]) diff --git a/wiki/installer.md b/wiki/installer.md index 09625ed1f..9e487ae0a 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -795,7 +795,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. @@ -821,6 +826,80 @@ 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. 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 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, 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, +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 @@ -1137,7 +1216,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 | @@ -1935,9 +2019,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`) | @@ -1981,7 +2065,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 | @@ -2010,11 +2094,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 diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index c889b0305..438572edf 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`, `test_site_handheld` | | 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` |