mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-11 12:33:53 -06:00
feat: remember the install and open on the plan
An install writes retrobios-backup/<folder>.install.json beside the BIOS folder: each file it placed with its sha1, size and source, and the choice it made. The next run for the same setup and folder takes that choice when the command line chose nothing, in line mode and on the full screen, and says so. A record that cannot be read is said once and ignored. A record event reports it. With one setup found, the full screen opens on its plan, Change where one row away. The plan's Install row says the size its headline says.
This commit is contained in:
1 parent
76ccc3e72a
commit
8f2018e7c2
25 files changed
+1621
-143
No files matched your search
+1
-1
@@ -16,7 +16,7 @@
|
||||
# and Invoke-WebRequest throws on a cut transfer before the hash is read:
|
||||
# the site's build rewrites this line there (scripts/sitebootstrap.py).
|
||||
$mismatchAdvice = "The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that."
|
||||
$defaultInstallSha256 = "456a32e79668e125f2ebe573b549247210c59fed6389e78dc1482b57d3a36712"
|
||||
$defaultInstallSha256 = "70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca"
|
||||
# The pin above refuses any changed byte: this cap only stops a body that
|
||||
# cannot be the installer (a sign-in portal's page) before it is hashed.
|
||||
$maximumInstallerBytes = 8MB
|
||||
|
||||
+596
-44
@@ -814,6 +814,15 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = {
|
||||
# when all were in place), kept the backup folders as in summary.
|
||||
# ok false with reason when it was refused or failed.
|
||||
"app": ("system", "folder", "ok", "reason", "dests", "written", "kept"),
|
||||
# The install record of the BIOS folder (path, beside it in
|
||||
# retrobios-backup). status: applied (before choices, its choice taken
|
||||
# as the run's because nothing on the command line chose), written
|
||||
# (before the summary, once the install placed files), unreadable (said
|
||||
# once and ignored) or not_written (the install itself unharmed). choice is
|
||||
# the recorded options as words, date when it was written, files the
|
||||
# files it holds for the platform. A kind of its own, since schema 1
|
||||
# never adds a key to an existing kind.
|
||||
"record": ("platform", "folder", "path", "status", "choice", "date", "files", "message"),
|
||||
"end": ("code",),
|
||||
}
|
||||
# Seconds between two progress events while bytes arrive.
|
||||
@@ -6099,6 +6108,270 @@ def _kept_lines() -> list[str]:
|
||||
return lines
|
||||
|
||||
|
||||
# The install record: what the runs placed in a BIOS folder, each file with
|
||||
# its hash, and the choice they made, which the next run's plan takes. It
|
||||
# lives in the backup holder beside the folder, never loose in it, for the
|
||||
# reason the backups do. On a card it travels with the card.
|
||||
RECORD_SCHEMA = 1
|
||||
RECORD_SUFFIX = ".install.json"
|
||||
# The options a record keeps as the choice: what the list was narrowed to.
|
||||
RECORD_CHOICE = ("--system", "--core", "--emulator", "--region", "--for-games",
|
||||
"--required-only", "--all-files", "--target", "--every-board",
|
||||
"--no-game-data", "--installed-cores", "--declared-only")
|
||||
# The record's choice options that narrow the list, --all-files aside.
|
||||
RECORD_NARROWING = tuple(flag for flag in RECORD_CHOICE if flag != "--all-files")
|
||||
_MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov",
|
||||
"Dec")
|
||||
# The records already said unreadable in this run, said once each.
|
||||
_RECORD_SAID: set[str] = set()
|
||||
# Per platform, the record whose choice this run took (apply_record).
|
||||
_RECORD_TAKEN: dict[str, dict] = {}
|
||||
|
||||
|
||||
def record_paths(folder: Path) -> list[Path]:
|
||||
"""Where the record of folder is looked for, in order: the holder beside
|
||||
it, then the one inside it that a folder with no writable parent gets."""
|
||||
folder = Path(os.path.abspath(folder))
|
||||
name = f"{folder.name or 'folder'}{RECORD_SUFFIX}"
|
||||
return [holder / name for holder, _prefix in KeptFiles._holders(folder)]
|
||||
|
||||
|
||||
def _valid_setup(setup: object) -> bool:
|
||||
if not isinstance(setup, dict):
|
||||
return False
|
||||
choice, files = setup.get("choice"), setup.get("files")
|
||||
return (isinstance(setup.get("written"), str) and isinstance(choice, list)
|
||||
and all(isinstance(word, str) for word in choice) and isinstance(files, list)
|
||||
and all(isinstance(item, dict) and isinstance(item.get("dest"), str)
|
||||
and isinstance(item.get("sha1"), str) and isinstance(item.get("size"), int)
|
||||
for item in files))
|
||||
|
||||
|
||||
def read_record(folder: Path) -> tuple[dict | None, Path | None, str]:
|
||||
"""(the record of folder, where it is, why it cannot be used). Neither a
|
||||
record nor a reason where there is none."""
|
||||
for path in record_paths(folder):
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
continue
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
reason = getattr(exc, "strerror", None) or str(exc)
|
||||
return None, path, f"The install record {path} cannot be read ({reason})"
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except ValueError:
|
||||
return None, path, f"The install record {path} is damaged"
|
||||
if not isinstance(data, dict) or not isinstance(data.get("setups"), dict):
|
||||
return None, path, f"The install record {path} is damaged"
|
||||
if data.get("schema") != RECORD_SCHEMA:
|
||||
return None, path, (f"The install record {path} was written by another "
|
||||
"version of the installer")
|
||||
if not all(_valid_setup(setup) for setup in data["setups"].values()):
|
||||
return None, path, f"The install record {path} is damaged"
|
||||
return data, path, ""
|
||||
return None, None, ""
|
||||
|
||||
|
||||
def setup_record(plat: str, folder: Path) -> tuple[dict | None, Path | None, str]:
|
||||
"""The record of plat in folder, where it is, and why it cannot be used.
|
||||
A record written for a folder of another name (copied, or renamed with
|
||||
it) is not this folder's."""
|
||||
data, path, problem = read_record(folder)
|
||||
if data is None:
|
||||
return None, path, problem
|
||||
written_for = str(data.get("folder") or "")
|
||||
if Path(written_for).name != Path(os.path.abspath(folder)).name:
|
||||
return None, path, ""
|
||||
return data["setups"].get(plat), path, ""
|
||||
|
||||
|
||||
def record_day(setup: dict) -> str:
|
||||
"""The day a record was written, as the plan says it: 3 Nov."""
|
||||
try:
|
||||
when = datetime.datetime.fromisoformat(setup["written"].replace("Z", "+00:00"))
|
||||
except (KeyError, ValueError):
|
||||
return ""
|
||||
local = when.astimezone()
|
||||
day = f"{local.day} {_MONTHS[local.month - 1]}"
|
||||
return day if local.year == datetime.datetime.now().year else f"{day} {local.year}"
|
||||
|
||||
|
||||
def record_options(argv: list[str]) -> list[tuple[str, str | None]]:
|
||||
"""A record's choice as (option, value) pairs."""
|
||||
options: list[tuple[str, str | None]] = []
|
||||
words = list(argv)
|
||||
while words:
|
||||
flag = words.pop(0)
|
||||
valued = flag in _VALUED_OPTIONS or (
|
||||
flag == "--for-games" and words and not words[0].startswith("--"))
|
||||
options.append((flag, words.pop(0) if valued and words else None))
|
||||
return options
|
||||
|
||||
|
||||
def record_argv(options: Iterable[tuple[str, str | None]]) -> list[str]:
|
||||
"""The choice options a record keeps, as words, in the command's order."""
|
||||
kept = sorted((option for option in options if option[0] in RECORD_CHOICE),
|
||||
key=_command_rank)
|
||||
if not any(flag in RECORD_NARROWING for flag, _value in kept):
|
||||
# The whole list, chosen as such: a card that holds games later
|
||||
# still gets what was installed before.
|
||||
kept = [("--all-files", None)]
|
||||
return [word for flag, value in kept for word in ([flag] if value is None else [flag, value])]
|
||||
|
||||
|
||||
def record_says(setup: dict, manifest: dict | None = None) -> str:
|
||||
"""The recorded choice in words: Game Boy Advance, the games, the
|
||||
options chosen, every file."""
|
||||
options = record_options(setup.get("choice") or [])
|
||||
systems = [system_title(value, manifest) for flag, value in options
|
||||
if flag == "--system" and value]
|
||||
if systems:
|
||||
shown = ", ".join(systems[:3])
|
||||
return shown + (f" and {len(systems) - 3} more" if len(systems) > 3 else "")
|
||||
if any(flag == "--for-games" for flag, _value in options):
|
||||
return "the games"
|
||||
if any(flag in RECORD_NARROWING for flag, _value in options):
|
||||
return "the options chosen"
|
||||
return "every file"
|
||||
|
||||
|
||||
def record_changes(setup: dict, entries: Iterable[dict]) -> tuple[int, int]:
|
||||
"""Of the files a choice keeps now, those the list changed since the
|
||||
record and those it did not hold then: (updated, new)."""
|
||||
recorded = {(str(item.get("root") or ""), item["dest"]): item["sha1"]
|
||||
for item in setup.get("files") or []}
|
||||
updated = new = 0
|
||||
for entry in entries:
|
||||
sha1 = recorded.get((str(entry.get("root") or ""), str(entry.get("dest"))))
|
||||
if sha1 is None:
|
||||
new += 1
|
||||
elif entry.get("sha1") and sha1 != entry.get("sha1"):
|
||||
updated += 1
|
||||
return updated, new
|
||||
|
||||
|
||||
def record_file(entry: dict, origin: str, root: str = "") -> dict:
|
||||
"""One file a run wrote, as the record keeps it."""
|
||||
return {"dest": str(entry["dest"]), "root": root or str(entry.get("root") or ""),
|
||||
"sha1": str(entry.get("sha1") or ""), "size": int(entry.get("size") or 0),
|
||||
"from": origin}
|
||||
|
||||
|
||||
def write_record(
|
||||
folder: Path, plat: str, manifest: dict, choice: list[str], files: list[dict],
|
||||
) -> tuple[Path | None, dict, str]:
|
||||
"""Write what this run placed in folder for plat, merged with what
|
||||
earlier runs placed: (where, the record of plat, why it was not
|
||||
written). A record that cannot be read is replaced."""
|
||||
folder = Path(os.path.abspath(folder))
|
||||
data, found, _problem = read_record(folder)
|
||||
if data is None:
|
||||
data = {"setups": {}}
|
||||
previous = data["setups"].get(plat) or {}
|
||||
merged = {(str(item.get("root") or ""), item["dest"]): item
|
||||
for item in previous.get("files") or []}
|
||||
for item in files:
|
||||
merged[(item["root"], item["dest"])] = item
|
||||
setup = {
|
||||
"written": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"list": {"version": manifest.get("version"), "generated": manifest.get("generated")},
|
||||
"choice": choice,
|
||||
"files": [merged[key] for key in sorted(merged)],
|
||||
}
|
||||
data = {"schema": RECORD_SCHEMA, "installer": installer_id(), "folder": str(folder),
|
||||
"setups": {**data["setups"], plat: setup}}
|
||||
text = json.dumps(data, separators=(",", ":"), sort_keys=True) + "\n"
|
||||
places = record_paths(folder)
|
||||
if found is not None and found in places:
|
||||
# Rewritten where it was read: never a second record of one folder.
|
||||
places = [found] + [place for place in places if place != found]
|
||||
failure = "nothing could be written beside the folder"
|
||||
for path in places:
|
||||
scratch: Path | None = None
|
||||
try:
|
||||
path.parent.mkdir(exist_ok=True)
|
||||
_check_holder(path.parent)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.",
|
||||
suffix=".part", delete=False) as handle:
|
||||
scratch = Path(handle.name)
|
||||
handle.write(text)
|
||||
os.replace(scratch, path)
|
||||
except OSError as exc:
|
||||
failure = exc.strerror or str(exc)
|
||||
if scratch is not None and os.path.lexists(scratch):
|
||||
try:
|
||||
scratch.unlink()
|
||||
except OSError as left:
|
||||
failure += f", and {scratch} stays ({left.strerror or left})"
|
||||
continue
|
||||
return path, setup, ""
|
||||
return None, setup, f"The install record could not be written ({failure})"
|
||||
|
||||
|
||||
def record_menu_words(setup: dict | None) -> str:
|
||||
"""What line mode's menu says of a recorded choice it opens on."""
|
||||
if setup is None:
|
||||
return ""
|
||||
day = record_day(setup)
|
||||
return f"Same choice as on {day}." if day else "Same choice as last time."
|
||||
|
||||
|
||||
def choice_given(args: argparse.Namespace) -> bool:
|
||||
"""Whether the command line chose what to install, which a record then
|
||||
never replaces: an option of the choice, or a file list of its own."""
|
||||
# A namespace built by a caller that predates an option lacks it.
|
||||
return (getattr(args, "for_games", None) is not None or LOCAL.manifest is not None
|
||||
or any(getattr(args, flag[2:].replace("-", "_"), None)
|
||||
for flag in RECORD_CHOICE if flag != "--for-games"))
|
||||
|
||||
|
||||
def say_record_problem(plat: str, folder: Path, path: Path | None, problem: str) -> None:
|
||||
"""A record that cannot be used, said once and ignored."""
|
||||
key = str(path)
|
||||
if not problem or key in _RECORD_SAID:
|
||||
return
|
||||
_RECORD_SAID.add(key)
|
||||
message = f"{problem}, so it is ignored."
|
||||
print(f" {message}")
|
||||
EVENTS.emit("record", platform=plat, folder=str(folder), path=key, status="unreadable",
|
||||
choice=[], date="", files=0, message=message)
|
||||
|
||||
|
||||
def apply_record(args: argparse.Namespace, plat: str, folder: Path) -> dict | None:
|
||||
"""Take the choice recorded for plat in folder as the run's, as if its
|
||||
options had been typed: the plan's first choice. Returns the record,
|
||||
None where none is taken."""
|
||||
setup, path, problem = setup_record(plat, folder)
|
||||
say_record_problem(plat, folder, path, problem)
|
||||
if setup is None:
|
||||
return None
|
||||
try:
|
||||
parsed = build_parser().parse_args(setup["choice"])
|
||||
except SystemExit:
|
||||
say_record_problem(plat, folder, path,
|
||||
f"The install record {path} names options this installer lacks")
|
||||
return None
|
||||
for flag in RECORD_CHOICE:
|
||||
name = flag[2:].replace("-", "_")
|
||||
setattr(args, name, getattr(parsed, name))
|
||||
if args.for_games is not None:
|
||||
args.all_files = False
|
||||
args.from_record = True
|
||||
options = record_options(setup["choice"])
|
||||
if any(flag in RECORD_NARROWING for flag, _value in options):
|
||||
# Said where it narrows the list: the whole list again is the run
|
||||
# a person expects, and said nothing new.
|
||||
_RECORD_TAKEN[plat] = setup
|
||||
_INVOCATION.chosen[plat] = [option for option in options if option[0] != "--all-files"]
|
||||
message = record_menu_words(setup)
|
||||
EVENTS.emit("record", platform=plat, folder=str(folder), path=str(path), status="applied",
|
||||
choice=list(setup["choice"]), date=setup["written"],
|
||||
files=len(setup["files"]), message=message)
|
||||
return setup
|
||||
|
||||
|
||||
def restore_kept(run: Path, dry_run: bool = False) -> int:
|
||||
"""Put every file of a backup run folder back where it was.
|
||||
|
||||
@@ -6769,6 +7042,14 @@ class Invocation:
|
||||
words += ["--dest", self.word(str(dest.absolute()))]
|
||||
elif dest:
|
||||
words += ["--dest", dest]
|
||||
for flag, value in self.flags(plat):
|
||||
if flag in without:
|
||||
continue
|
||||
words += [flag] if value is None else [flag, self.word(value)]
|
||||
return " ".join(words)
|
||||
|
||||
def flags(self, plat: str) -> list[tuple[str, str | None]]:
|
||||
"""The options of the run for plat, in the command's order."""
|
||||
options = self.options
|
||||
if plat in self.chosen:
|
||||
# A choice made at the questions replaces the narrowing the
|
||||
@@ -6776,11 +7057,7 @@ class Invocation:
|
||||
# the systems of the first run and of the second.
|
||||
replaced = {*_CHOICE_OPTIONS, *(flag for flag, _value in self.chosen[plat])}
|
||||
options = [option for option in options if option[0] not in replaced]
|
||||
for flag, value in sorted([*options, *self.chosen.get(plat, [])], key=_command_rank):
|
||||
if flag in without:
|
||||
continue
|
||||
words += [flag] if value is None else [flag, self.word(value)]
|
||||
return " ".join(words)
|
||||
return sorted([*options, *self.chosen.get(plat, [])], key=_command_rank)
|
||||
|
||||
|
||||
_INVOCATION = Invocation()
|
||||
@@ -7956,13 +8233,15 @@ def install_data_directory(entry: dict, bios_path: Path,
|
||||
|
||||
Returns counts: present, installed, changed (the source no longer holds
|
||||
the recorded bytes), absent (not in the archive), error, the reason
|
||||
the archive could not be read, and not_kept, the (path, reason) of each
|
||||
file in place that differs and could not be moved aside, left as it is.
|
||||
the archive could not be read, not_kept, the (path, reason) of each
|
||||
file in place that differs and could not be moved aside, left as it is,
|
||||
and written, each file written (dest below the BIOS folder, sha1, size).
|
||||
"""
|
||||
root, needed, present = state or data_directory_state(entry, bios_path)
|
||||
needed = dict(needed)
|
||||
written: list[dict] = []
|
||||
counts = {"present": present, "installed": 0, "changed": 0, "absent": 0, "error": "",
|
||||
"not_kept": []}
|
||||
"not_kept": [], "written": written}
|
||||
if not needed:
|
||||
return counts
|
||||
_writing.set()
|
||||
@@ -7999,12 +8278,13 @@ def install_data_directory(entry: dict, bios_path: Path,
|
||||
continue
|
||||
target = _destination_path(root, rel)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
slot = f"{entry['dest']}/{rel}" if entry.get("dest") else rel
|
||||
outcome = _install_data_member(
|
||||
opener, meta, target, bios_path,
|
||||
f"{entry['dest']}/{rel}" if entry.get("dest") else rel,
|
||||
rel, counts["not_kept"])
|
||||
opener, meta, target, bios_path, slot, rel, counts["not_kept"])
|
||||
if outcome:
|
||||
counts[outcome] += 1
|
||||
if outcome == "installed":
|
||||
written.append({"dest": slot, "sha1": meta["sha1"], "size": meta["size"]})
|
||||
except _Offline as exc:
|
||||
# Kept apart, so the advice is the offline one, not the network's.
|
||||
counts["error"] = OfflineReason(str(exc))
|
||||
@@ -9595,6 +9875,11 @@ def _prompt_custom_selection(
|
||||
elif playing and played is not None:
|
||||
kept_games = menu_choice(files, omitted, installed, GamesChoice(played), cutting)[0]
|
||||
print(f" h) only for the games {played.where()}: {_sized(kept_games)}")
|
||||
# The screen's "Same choice as last time: no": the whole list.
|
||||
widen = (plat in _RECORD_TAKEN and current is not None
|
||||
and not (playing and isinstance(current, GamesChoice)))
|
||||
if widen:
|
||||
print(f" e) everything: {_sized(shown)}")
|
||||
if installed is not None and current != INSTALLED_ONLY:
|
||||
print(f" i) only what the installed cores read: {_sized(installed[0])}")
|
||||
# What c asks, named: "systems, cores and regions" was offered with
|
||||
@@ -9614,6 +9899,7 @@ def _prompt_custom_selection(
|
||||
print(" q) quit")
|
||||
keys = [*([] if emptied else [""]), "q", *(["c"] if choosable else []),
|
||||
*((["r", "e"] if isinstance(current, GamesChoice) else ["h"]) if playing else []),
|
||||
*(["e"] if widen else []),
|
||||
*(["i"] if installed is not None and current != INSTALLED_ONLY else []),
|
||||
*(["f"] if copying else []), *(["g"] if games.games else [])]
|
||||
others = [key for key in keys if key not in ("", "q")]
|
||||
@@ -9668,10 +9954,12 @@ def _print_menu_head(
|
||||
if current == INSTALLED_ONLY:
|
||||
named = ", only what the installed cores read"
|
||||
elif isinstance(current, GamesChoice):
|
||||
named = f", {current.words()}" + (" (--for-games)" if current is given else "")
|
||||
named = f", {current.words()}" + (" (--for-games)" if current is given
|
||||
and plat not in _RECORD_TAKEN else "")
|
||||
elif isinstance(current, tuple):
|
||||
systems, cores, regions = current
|
||||
flag = current is given
|
||||
# A recorded choice was typed by nobody: no option is named.
|
||||
flag = current is given and plat not in _RECORD_TAKEN
|
||||
listed = choosable_systems(files, omitted, cores, regions)
|
||||
total = len(listed)
|
||||
# The systems chosen that still hold a file: Sega Dreamcast holding
|
||||
@@ -10206,6 +10494,20 @@ def _narrow_to_emulators(
|
||||
return files, omitted
|
||||
|
||||
|
||||
def _known_names(values: list[str] | None, known: list[str], label: str,
|
||||
aliases: dict[str, str] | None = None) -> list[str]:
|
||||
"""The names of values that resolve against known, the others left out."""
|
||||
kept = []
|
||||
for value in values or []:
|
||||
try:
|
||||
resolve_choices([value], known, label, aliases)
|
||||
except ValueError:
|
||||
detail(f" {value} is no longer on the list: left out of the recorded choice")
|
||||
continue
|
||||
kept.append(value)
|
||||
return kept
|
||||
|
||||
|
||||
def _requested_narrowing(
|
||||
args: argparse.Namespace, plat: str, files: list[dict], omitted: list[dict],
|
||||
manifest: dict | None = None,
|
||||
@@ -10234,6 +10536,14 @@ def _requested_narrowing(
|
||||
print(_narrowing_advice(plat, axis, flag, recorded, pack_alone), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
choices = available_choices(files, omitted)
|
||||
if getattr(args, "from_record", False):
|
||||
# A name the list no longer holds is dropped from a recorded choice,
|
||||
# never refused: nobody typed it.
|
||||
args.system = _known_names(args.system, choices["systems"], "system",
|
||||
system_aliases(manifest)) or None
|
||||
args.core = _known_names(args.core, choices["cores"], "core") or None
|
||||
if not (args.system or args.core or args.region):
|
||||
return None
|
||||
try:
|
||||
systems = resolve_choices(
|
||||
args.system or [], choices["systems"], "system", system_aliases(manifest),
|
||||
@@ -16101,6 +16411,42 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
# The BIOS folders read for misplaced files, one per platform.
|
||||
bios_indexes: list[FolderIndex] = []
|
||||
|
||||
# The choice the folder's record keeps is the plan's first choice, taken
|
||||
# as its options would be: before the checks of --target and
|
||||
# --installed-cores, which it may hold. Never where the command line
|
||||
# chose, nor for a run over several setups.
|
||||
_RECORD_TAKEN.clear()
|
||||
|
||||
def remember(plat: str, folder: Path, manifest: dict, written: list[dict],
|
||||
games: bool) -> None:
|
||||
"""Record what this install placed in folder and the choice it made,
|
||||
games being the card's games applied with no --for-games."""
|
||||
if not written and setup_record(plat, folder)[0] is None:
|
||||
# Nothing of ours is there to record, and no choice to keep.
|
||||
return
|
||||
options = _INVOCATION.flags(plat)
|
||||
if games:
|
||||
options += [("--for-games", None),
|
||||
*([("--required-only", None)] if args.required_only else [])]
|
||||
choice = record_argv(options)
|
||||
path, setup, problem = write_record(folder, plat, manifest, choice, written)
|
||||
if problem:
|
||||
detail(f" {problem}.")
|
||||
count = len(setup["files"])
|
||||
EVENTS.emit("record", platform=plat, folder=str(folder),
|
||||
path=str(path or record_paths(folder)[0]),
|
||||
status="not_written" if problem else "written", choice=choice,
|
||||
date=setup["written"], files=count,
|
||||
message=f"{problem}." if problem else
|
||||
f"Recorded {_plural(count, 'file')} and this choice in {path}.")
|
||||
|
||||
if len(platforms) == 1 and not listing and not choice_given(args):
|
||||
if apply_record(args, *platforms[0]) is not None and args.target:
|
||||
known = fetch_targets(platforms[0][0])
|
||||
if known is not None and args.target not in known:
|
||||
# A board the list no longer names: every board's files.
|
||||
args.target = None
|
||||
|
||||
# --target is checked for every platform before anything is written, as
|
||||
# --installed-cores is below: EmuDeck installed 3.2 GB and only then was
|
||||
# steamos refused for RetroArch, with no summary of what had landed.
|
||||
@@ -16409,6 +16755,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
+ ([("--no-game-data", None)] if cut and cut[0] else []))
|
||||
return _INVOCATION.command(plat, folder)
|
||||
|
||||
# The record's options, which the menu's commands overwrite.
|
||||
recorded = _INVOCATION.chosen.get(plat_name)
|
||||
selection = _prompt_custom_selection(
|
||||
plat_name, bare, bare_omitted + unplaced, installed,
|
||||
detected_os(plat_name, bios_path) or display,
|
||||
@@ -16417,14 +16765,18 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
bool(target or args.installed_cores or args.declared_only
|
||||
or len(files) + game_data_cut + len(unplaced) != listed),
|
||||
command_for, cut,
|
||||
game_data_only_words([system_title(system, manifest) for system in
|
||||
game_data_only(given[0] if isinstance(given, tuple)
|
||||
else [], files)])
|
||||
if args.no_game_data else "",
|
||||
" ".join(filter(None, [
|
||||
record_menu_words(_RECORD_TAKEN.get(plat_name)),
|
||||
game_data_only_words([system_title(system, manifest) for system in
|
||||
game_data_only(given[0] if isinstance(given, tuple)
|
||||
else [], files)])
|
||||
if args.no_game_data else ""])),
|
||||
None if games_applied else games_plan,
|
||||
)
|
||||
# Its head named the BIOS folder: the result does not again.
|
||||
OUTPUT.where_said = True
|
||||
kept_record = plat_name in _RECORD_TAKEN and selection is not None and (
|
||||
selection is given or selection == given)
|
||||
if selection is None and LOCAL.pack is not None:
|
||||
smaller = " Nothing copied."
|
||||
elif selection is None:
|
||||
@@ -16456,6 +16808,12 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
_INVOCATION.chosen[plat_name] = []
|
||||
else:
|
||||
_INVOCATION.chosen.pop(plat_name, None)
|
||||
if kept_record and recorded is not None:
|
||||
# Enter on the recorded choice: still the record's options.
|
||||
_INVOCATION.chosen[plat_name] = recorded
|
||||
elif selection is None and plat_name in _RECORD_TAKEN and not games_applied:
|
||||
# e: every file, the recorded narrowing given up.
|
||||
args.system = args.core = args.region = None
|
||||
if selection is not None:
|
||||
before = len(files)
|
||||
files, omitted_files = narrow(files, omitted_files, *selection)
|
||||
@@ -16567,6 +16925,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
bios_path = library
|
||||
|
||||
detail("\nChecking existing files...")
|
||||
record_files: list[dict] = []
|
||||
to_download, up_to_date, mismatched = check_local(
|
||||
files, bios_path, jobs=args.jobs, term=term
|
||||
)
|
||||
@@ -16672,6 +17031,10 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
else:
|
||||
print(f"\n {EVERYTHING_IN_PLACE}")
|
||||
print(f" BIOS folder: {shown_path(bios_path)}")
|
||||
if plat_name in _RECORD_TAKEN:
|
||||
# The files counted are the recorded choice's, not the list's.
|
||||
print(f" {record_menu_words(_RECORD_TAKEN[plat_name])} "
|
||||
"--all-files checks every file.")
|
||||
absent = _absent_required(omitted_files, bios_path)
|
||||
# The collection cannot supply these, so they leave the exit
|
||||
# code alone, but a user asking why a game will not start needs
|
||||
@@ -16710,6 +17073,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
trees, kept, copy_plan_lines(to_download, copies, bios_index,
|
||||
bios_path, files, args.verbose, True),
|
||||
verbose=args.verbose, manifest=manifest)
|
||||
if plat_name in _RECORD_TAKEN:
|
||||
print(f" {record_menu_words(_RECORD_TAKEN[plat_name])} "
|
||||
"--all-files lists every file.")
|
||||
for line in unreadable_lines(unread, args.verbose):
|
||||
print(line)
|
||||
for line in hint:
|
||||
@@ -16784,9 +17150,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
coming = len(to_download) - len(lacking) + trees_missing
|
||||
weight = dl_size + sum(data_fetch_bytes(entry, state[1])
|
||||
for entry, state in data_states)
|
||||
taken = _RECORD_TAKEN.get(plat_name)
|
||||
print(f"\nInstalling {_plural(coming, 'file')} ({format_size(weight)}) for "
|
||||
f"{detected_os(plat_name, bios_path) or display}"
|
||||
+ (", what the games need" if games_applied else "") + ".")
|
||||
+ (", what the games need" if games_applied else "")
|
||||
+ (f", same choice as on {record_day(taken)}" if taken else "") + ".")
|
||||
print(f"BIOS folder: {shown_path(bios_path)}")
|
||||
OUTPUT.where_said = True
|
||||
if unread:
|
||||
@@ -16878,6 +17246,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
# counted them, over the files that landed: matched by the end
|
||||
# of a failed path, a.bin was dropped when x/a.bin failed.
|
||||
landed.extend(progress.landed)
|
||||
record_files += [record_file(entry, "copy" if id(entry) in copies
|
||||
else "pack" if LOCAL.pack is not None else "download")
|
||||
for entry in progress.landed]
|
||||
files_pending += len(to_download) - progress.installed if interrupted \
|
||||
else len(failed)
|
||||
total_errors += len(failed)
|
||||
@@ -16886,6 +17257,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
total_seconds += progress.elapsed()
|
||||
if interrupted:
|
||||
total_interrupted = True
|
||||
remember(plat_name, bios_path, manifest, record_files,
|
||||
games_applied and args.for_games is None)
|
||||
break
|
||||
elif len(platforms) > 1:
|
||||
# One setup's run ends on "Done. Everything is in place.": said
|
||||
@@ -16931,6 +17304,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
total_seconds += progress.elapsed()
|
||||
total_downloaded += counts["installed"]
|
||||
data_landed += counts["installed"]
|
||||
record_files += [record_file(item, "pack" if LOCAL.pack is not None
|
||||
else "download", "data")
|
||||
for item in counts.get("written", [])]
|
||||
total_up_to_date += counts["present"]
|
||||
if isinstance(counts["error"], OfflineReason):
|
||||
# Left out as the BIOS files above are: counted missing,
|
||||
@@ -16971,6 +17347,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
failures.append((where, f"{NOT_KEPT} ({reason})"))
|
||||
total_errors += 1
|
||||
|
||||
remember(plat_name, bios_path, manifest, record_files,
|
||||
games_applied and args.for_games is None)
|
||||
|
||||
if os_type == "android" and not args.check and hide_from_media_scanner(bios_path):
|
||||
detail(f" Added {NOMEDIA} to {shown_path(bios_path)}: the gallery and music "
|
||||
"apps leave its images, sounds and videos out")
|
||||
@@ -18019,6 +18398,16 @@ class TuiState:
|
||||
self.games: dict | None = None
|
||||
self.games_reading: tuple | None = None
|
||||
self.games_unavailable: dict | None = None
|
||||
# The install record of the setup and folder chosen (read for
|
||||
# record_for), whose choice the plan opens on when the command line
|
||||
# chose nothing (cli_choice), and the options of it no row shows.
|
||||
self.record: dict | None = None
|
||||
self.record_for: tuple | None = None
|
||||
self.record_flags: list = []
|
||||
self.cli_choice: bool = False
|
||||
# Opened on the plan of the one setup found: its Change where row
|
||||
# goes back to the setups.
|
||||
self.opened_on_plan: bool = False
|
||||
|
||||
@property
|
||||
def screen(self) -> str:
|
||||
@@ -18475,6 +18864,12 @@ def tui_flags(state: TuiState) -> list[tuple[str, str | None]]:
|
||||
flags.append(("--installed-cores", None))
|
||||
if state.jobs != 8:
|
||||
flags.append(("--jobs", str(state.jobs)))
|
||||
flags += state.record_flags
|
||||
if (state.record is not None and not state.for_games
|
||||
and not any(flag in RECORD_NARROWING for flag, _value in flags)):
|
||||
# The whole list, chosen over a recorded narrower one: said so to
|
||||
# the runs, whose own reading of the record would narrow it again.
|
||||
flags.append(("--all-files", None))
|
||||
flags += [("--from", str(folder)) for folder in state.from_folders]
|
||||
if (os.environ.get(OFFLINE_VARIABLE) == OFFLINE_FLAG_VALUE
|
||||
and not any(flag == "--offline" for flag, _v in flags)):
|
||||
@@ -18960,14 +19355,12 @@ def _plan_rows(state: TuiState) -> list[TuiRow]:
|
||||
return rows + ([game_data] if game_data is not None else []) + [
|
||||
*_details_row(state), TuiRow("options", "Options"), TuiRow("quit", "Quit")]
|
||||
if not (_needed(check) if check else 0) and not check.get("data_missing"):
|
||||
return [TuiRow("close", "Close"), *_games_rows(state), *_details_row(state),
|
||||
TuiRow("options", "Options")]
|
||||
# The size the pack can give: "Install 6.6 GB" from a pack holding
|
||||
# none of the files.
|
||||
lacking = set(_tui_pack_lacks(state, [event["dest"] for event in state.files]))
|
||||
size = (_ticked_estimate(state)[1] if _tui_estimating(state)
|
||||
else check.get("bytes_needed", 0) - sum(event["size"] for event in state.files
|
||||
if event["dest"] in lacking))
|
||||
return [TuiRow("close", "Close"), *_games_rows(state), *_record_rows(state),
|
||||
*_where_rows(state), *_details_row(state), TuiRow("options", "Options")]
|
||||
# The size the headline says: "Install 6.6 GB" stood under "14930 files
|
||||
# to download, 6.8 GB", the copies counted in one and the data files in
|
||||
# the other.
|
||||
size = _ticked_estimate(state)[1] if _tui_estimating(state) else _plan_fetch(state, check)[1]
|
||||
label = (f"{TUI_INSTALL_ROW} {format_size(size)}" if size >= CONFIRM_FROM_BYTES
|
||||
else TUI_INSTALL_ROW)
|
||||
rows = [TuiRow("install", label)]
|
||||
@@ -19006,6 +19399,7 @@ def _plan_rows(state: TuiState) -> list[TuiRow]:
|
||||
# Right under Install: the choice the plan opened on, the whole list one
|
||||
# press away.
|
||||
rows += _games_rows(state)
|
||||
rows += _record_rows(state)
|
||||
if "systems" in recorded_axes((state.manifest or {}).get("files", [])) and (
|
||||
len(state.systems) > 1 or state.cli_systems):
|
||||
rows.append(TuiRow("systems", TUI_SYSTEMS_ROW))
|
||||
@@ -19018,7 +19412,27 @@ def _plan_rows(state: TuiState) -> list[TuiRow]:
|
||||
rows.append(TuiRow("misplaced", "Show the files in the wrong place"))
|
||||
if state.unreadable:
|
||||
rows.append(TuiRow("unreadable", "Show the files that could not be read"))
|
||||
return rows + [*_details_row(state), TuiRow("options", "Options"), TuiRow("quit", "Quit")]
|
||||
return rows + [*_where_rows(state), *_details_row(state), TuiRow("options", "Options"),
|
||||
TuiRow("quit", "Quit")]
|
||||
|
||||
|
||||
# The plan's row for the record's choice, and the one back to the setups.
|
||||
TUI_RECORD_ROW = "Same choice as last time"
|
||||
TUI_WHERE_ROW = TuiRow("where", "Change where")
|
||||
|
||||
|
||||
def _record_rows(state: TuiState) -> list[TuiRow]:
|
||||
"""Same choice as last time: yes or no, where the folder has a record."""
|
||||
if state.record is None or state.cli_choice:
|
||||
return []
|
||||
day = record_day(state.record)
|
||||
label = f"Same choice as on {day}" if day else TUI_RECORD_ROW
|
||||
return [TuiRow("record", f"{label}: {_yes(tui_record_kept(state))}")]
|
||||
|
||||
|
||||
def _where_rows(state: TuiState) -> list[TuiRow]:
|
||||
"""Change where, on a plan opened straight on the one setup found."""
|
||||
return [TUI_WHERE_ROW] if state.opened_on_plan and state.stack[:1] == ["setup"] else []
|
||||
|
||||
|
||||
# The plan's explanations, counts and reasons, one press away: a pad has
|
||||
@@ -19503,9 +19917,88 @@ def _start_run(state: TuiState, run: str) -> list[tuple]:
|
||||
return [("run", run)]
|
||||
|
||||
|
||||
def _record_key(options: Iterable[tuple[str, str | None]]) -> list:
|
||||
"""A choice in a form two spellings of it share: one name per --system
|
||||
and --core, in any order."""
|
||||
pairs = record_options(record_argv(options))
|
||||
return sorted((flag, name.strip()) for flag, value in pairs
|
||||
for name in ((value or "").split(",") if flag in ("--system", "--core")
|
||||
else [value or ""]) if name.strip() or value is None)
|
||||
|
||||
|
||||
def tui_record_kept(state: TuiState) -> bool:
|
||||
"""Whether the plan's choice is the one the record keeps."""
|
||||
if state.record is None:
|
||||
return False
|
||||
return _record_key(tui_flags(state)) == _record_key(
|
||||
record_options(state.record["choice"]))
|
||||
|
||||
|
||||
def _tui_record_take(state: TuiState) -> None:
|
||||
"""The recorded choice made the screen's, as its options would make it."""
|
||||
setup = state.record or {}
|
||||
try:
|
||||
parsed = build_parser().parse_args(setup.get("choice") or [])
|
||||
except SystemExit:
|
||||
state.status = "The install record names options this installer lacks, so it is ignored."
|
||||
state.record = None
|
||||
return
|
||||
state.cli_systems = list(parsed.system or [])
|
||||
state.cores = [token.strip() for value in parsed.core or [] for token in value.split(",")
|
||||
if token.strip()]
|
||||
try:
|
||||
state.regions = resolve_regions(parsed.region) if parsed.region else []
|
||||
except ValueError:
|
||||
state.regions = []
|
||||
state.no_game_data = bool(parsed.no_game_data)
|
||||
state.installed_only = bool(parsed.installed_cores)
|
||||
state.for_games = parsed.for_games is not None
|
||||
state.games_folder = parsed.for_games if isinstance(parsed.for_games, Path) else None
|
||||
state.required_only = bool(parsed.required_only) and state.for_games
|
||||
state.games_choice = "record"
|
||||
state.record_flags = [(flag, value) for flag, value in record_options(setup["choice"])
|
||||
if flag in ("--target", "--emulator", "--every-board",
|
||||
"--declared-only")]
|
||||
|
||||
|
||||
def _tui_record_drop(state: TuiState) -> None:
|
||||
"""The recorded choice given up: the choice nothing narrows."""
|
||||
state.cli_systems, state.cores, state.regions, state.record_flags = [], [], [], []
|
||||
state.no_game_data = state.installed_only = state.required_only = False
|
||||
state.for_games, state.games_folder, state.games_choice = None, None, ""
|
||||
# The systems of the recorded check go, and with them its ticks.
|
||||
state.systems, state.ticked, state.keep_ticked, state.widened_from = [], set(), None, None
|
||||
|
||||
|
||||
def _tui_record_load(state: TuiState) -> None:
|
||||
"""Read the record of the setup and folder chosen, once for each, and
|
||||
take its choice where nothing chose: the command line, or the screen
|
||||
for an earlier folder."""
|
||||
key = (state.platform, state.dest)
|
||||
if state.dest is None or state.record_for == key:
|
||||
return
|
||||
taken = tui_record_kept(state)
|
||||
state.record_for = key
|
||||
setup, path, problem = setup_record(state.platform, state.dest)
|
||||
if problem and str(path) not in _RECORD_SAID:
|
||||
_RECORD_SAID.add(str(path))
|
||||
state.status = f"{problem}, so it is ignored."
|
||||
if taken:
|
||||
_tui_record_drop(state)
|
||||
# Games read on a card are what the plan found, not a choice.
|
||||
found = ("--for-games", "--required-only") if state.games_choice in ("", "probe") else ()
|
||||
state.record = None
|
||||
untouched = not any(flag in RECORD_NARROWING and flag not in found
|
||||
for flag, _value in tui_flags(state))
|
||||
state.record = setup
|
||||
if setup is not None and not state.cli_choice and untouched:
|
||||
_tui_record_take(state)
|
||||
|
||||
|
||||
def _start_check(state: TuiState) -> list[tuple]:
|
||||
"""Check the chosen folder, after fetching the file list once, the
|
||||
systems ticked kept (_keep_choice)."""
|
||||
_tui_record_load(state)
|
||||
_keep_choice(state)
|
||||
_games_look(state)
|
||||
state.refocus = ""
|
||||
@@ -19528,7 +20021,7 @@ def _games_look(state: TuiState) -> None:
|
||||
looked at too, from what this run already read (no network): its games
|
||||
make "For the games on this card" the choice, unless the user or the
|
||||
command line decided."""
|
||||
if (state.games_choice in ("user", "cli") or state.games_probed is None
|
||||
if (state.games_choice in ("user", "cli", "record") or state.games_probed is None
|
||||
or state.dest is None or state.games_probed == (state.platform, state.dest)):
|
||||
return
|
||||
count = games_probe(state.platform, state.dest, network=False, os_type=state.os_type)
|
||||
@@ -20130,6 +20623,28 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]:
|
||||
return _push(state, action)
|
||||
if action == "recheck":
|
||||
return _start_check(state)
|
||||
if action == "where":
|
||||
effects = [("kill",)] if state.running else []
|
||||
state.running, state.run_id = False, state.run_id + 1
|
||||
del state.stack[1:]
|
||||
return effects
|
||||
if action == "record":
|
||||
# No: the choice nothing narrows, the card's games when it holds
|
||||
# some. Yes: the recorded choice again. Counted again either way.
|
||||
if tui_record_kept(state):
|
||||
_tui_record_drop(state)
|
||||
if state.dest is not None:
|
||||
count = games_probe(state.platform, state.dest, network=False,
|
||||
os_type=state.os_type)
|
||||
state.games_probed = (state.platform, state.dest)
|
||||
state.games_here = count or 0
|
||||
state.for_games, state.games_choice = bool(count), "probe"
|
||||
else:
|
||||
_tui_record_drop(state)
|
||||
_tui_record_take(state)
|
||||
effects = _start_check(state)
|
||||
state.refocus = "record"
|
||||
return effects
|
||||
if action in ("games", "startonly"):
|
||||
# One press, then counted again: the games decide the systems,
|
||||
# so the ticks start again from every system they need.
|
||||
@@ -21485,7 +22000,7 @@ def tui_receive(state: TuiState, message: tuple) -> list[tuple]:
|
||||
_kind, plat, dest, count = message
|
||||
if plat == state.platform and dest == state.dest:
|
||||
state.games_probed = (plat, dest)
|
||||
if state.games_choice not in ("user", "cli"):
|
||||
if state.games_choice not in ("user", "cli", "record"):
|
||||
state.games_here = count or 0
|
||||
state.for_games, state.games_choice = bool(count), "probe"
|
||||
return []
|
||||
@@ -21985,25 +22500,36 @@ def _last_name(folder: str) -> str:
|
||||
return Path(folder).name or folder
|
||||
|
||||
|
||||
def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list:
|
||||
need = _needed(check)
|
||||
data = check.get("data_missing", 0)
|
||||
# A file found on this machine is copied, not downloaded: the count and
|
||||
# the size to download leave it out, from the check's own events. On
|
||||
# Confirm the dry run's files are matched the same way: it called the
|
||||
# files a --from folder holds downloads, and sent an offline user for
|
||||
# a pack.
|
||||
def _plan_fetch(state: TuiState, check: dict) -> tuple[int, int, list, list, dict]:
|
||||
"""(files to fetch, bytes the install fetches with the data files',
|
||||
dests copied from this machine, dests the pack lacks, the size of each
|
||||
file): the one size the plan's headline and its Install row say.
|
||||
|
||||
A file found on this machine is copied, not downloaded: the count and
|
||||
the size to download leave it out, from the check's own events. On
|
||||
Confirm the dry run's files are matched the same way: it called the
|
||||
files a --from folder holds downloads, and sent an offline user for a
|
||||
pack. The files the pack does not hold are left out too: the plan
|
||||
offered "Install 6.6 GB" from a pack that held none of them.
|
||||
"""
|
||||
dry = check is state.dry and check is not state.check
|
||||
sizes = {event["dest"]: event["size"]
|
||||
for event in (state.dry_files if dry else state.files)}
|
||||
copied = _plan_copies(state, check) if dry or check is state.check else []
|
||||
# The files the pack does not hold are said before a size is promised,
|
||||
# and left out of it: the plan offered "Install 6.6 GB" from a pack that
|
||||
# held none of them.
|
||||
lacking = _tui_pack_lacks(state, [dest_ for dest_ in sizes if dest_ not in copied])
|
||||
fetch = max(0, need - len(copied) - len(lacking))
|
||||
fetch_bytes = max(0, check["bytes_needed"] - sum(sizes[dest_] for dest_ in copied)
|
||||
fetch = max(0, _needed(check) - len(copied) - len(lacking))
|
||||
fetch_bytes = max(0, check.get("bytes_needed", 0) - sum(sizes[dest_] for dest_ in copied)
|
||||
- sum(sizes[dest_] for dest_ in lacking))
|
||||
data_bytes = check.get("data_bytes", 0) if check.get("data_missing", 0) else 0
|
||||
return fetch, fetch_bytes + data_bytes, copied, lacking, sizes
|
||||
|
||||
|
||||
def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list:
|
||||
need = _needed(check)
|
||||
data = check.get("data_missing", 0)
|
||||
dry = check is state.dry and check is not state.check
|
||||
fetch, fetched, copied, lacking, sizes = _plan_fetch(state, check)
|
||||
fetch_bytes = fetched - (check.get("data_bytes", 0) if data else 0)
|
||||
elsewhere = (_tui_found_elsewhere(state, set(copied) if dry else None)
|
||||
if check is state.check or dry else {})
|
||||
offline = _offline_only()
|
||||
@@ -22603,6 +23129,23 @@ def _plan_text(state: TuiState) -> list:
|
||||
said = ", ".join(named)
|
||||
if said and not said.startswith("for "):
|
||||
said = f"for this choice: {said}"
|
||||
since: list = []
|
||||
if tui_record_kept(state) and not state.cli_choice and state.record is not None:
|
||||
# The record's systems by name, its row saying whose choice it is:
|
||||
# nobody typed --system. The games and the other options are said
|
||||
# as the plan says them.
|
||||
what = record_says(state.record, state.manifest)
|
||||
if what == "every file":
|
||||
said = ""
|
||||
elif what not in ("the games", "the options chosen"):
|
||||
said = f"for {what}"
|
||||
updated, new = record_changes(state.record, [
|
||||
entry for entry in (state.manifest or {}).get("files") or []
|
||||
if entry.get("dest") in state.checked_dests])
|
||||
changes = [*([f"{_plural(updated, 'file')} updated in the list"] if updated else []),
|
||||
*([f"{new} new"] if new else [])]
|
||||
if changes:
|
||||
since = [_detail(f"Since then: {', '.join(changes)}.")]
|
||||
# A system --system named that --no-game-data left empty, in line mode's
|
||||
# words.
|
||||
listed = {row["system"] for row in state.systems}
|
||||
@@ -22641,7 +23184,7 @@ def _plan_text(state: TuiState) -> list:
|
||||
f"{parts}")
|
||||
lines[at:at] = [*([_detail(state.card_note)] if state.card_note
|
||||
and state.card_note != state.status else []),
|
||||
*([f"{said[0].upper()}{said[1:]}."] if said else []),
|
||||
*([f"{said[0].upper()}{said[1:]}."] if said else []), *since,
|
||||
*([emptied_named] if emptied_named else []), *choice,
|
||||
*([_detail(parts)] if parts else []), *games_plan_words(state)]
|
||||
if state.version:
|
||||
@@ -24065,6 +24608,7 @@ def tui_start(
|
||||
if args.manifest is not None:
|
||||
state.manifest_path = Path(args.manifest).absolute()
|
||||
state.manifest_given = True
|
||||
state.cli_choice = choice_given(args) or state.manifest_given
|
||||
plat = normalize_platform(args.platform, say=False) if args.platform else ""
|
||||
if detect:
|
||||
captured = io.StringIO()
|
||||
@@ -24137,6 +24681,14 @@ def tui_start(
|
||||
if state.dest_given is None and not args.platform:
|
||||
state.doubts = [(plat, mount) for mount in _mounted_cards()
|
||||
for plat in share_doubt(mount)[0]]
|
||||
if (state.dest_given is None and not state.doubts and not state.refused
|
||||
and len(state.setups) == 1):
|
||||
# One setup found and no card in doubt: its plan, Change where (or
|
||||
# Back) one press away. A folder detection could not look in may
|
||||
# hold another setup: the setups are listed then, with it.
|
||||
found, folder, _label = state.setups[0]
|
||||
state.opened_on_plan = True
|
||||
return state, _choose(state, found, folder, proposed=True)
|
||||
if state.dest_given is not None or not (state.setups or state.doubts):
|
||||
state.stack = ["platforms"]
|
||||
state.focus["platforms"] = _platform_focus(state, dest_default(state.setups))
|
||||
@@ -24732,7 +25284,7 @@ def run_tui(args: argparse.Namespace) -> int:
|
||||
if kind == "fetch":
|
||||
threading.Thread(target=_tui_fetch_games,
|
||||
args=(effect[1], inbox, buffer, state.dest,
|
||||
state.games_choice not in ("user", "cli")),
|
||||
state.games_choice not in ("user", "cli", "record")),
|
||||
daemon=True).start()
|
||||
elif kind == "run":
|
||||
for run in current:
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/ins
|
||||
# curl or wget stop on a cut transfer before the hash is read: the site's
|
||||
# build rewrites this line there (scripts/sitebootstrap.py).
|
||||
MISMATCH_ADVICE="The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that."
|
||||
DEFAULT_INSTALL_SHA256="456a32e79668e125f2ebe573b549247210c59fed6389e78dc1482b57d3a36712"
|
||||
DEFAULT_INSTALL_SHA256="70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca"
|
||||
# The pin above refuses any changed byte: this cap only stops a body that
|
||||
# cannot be the installer (a sign-in portal's page) before it is hashed.
|
||||
MAX_INSTALLER_BYTES=8388608
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
{
|
||||
"dest": "roms/ports/retrobios/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 1162452,
|
||||
"sha256": "456a32e79668e125f2ebe573b549247210c59fed6389e78dc1482b57d3a36712",
|
||||
"size": 1189759,
|
||||
"sha256": "70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
@@ -71,8 +71,8 @@
|
||||
"muos": {
|
||||
"archive": {
|
||||
"dest": "ARCHIVE/RetroBIOS.muxapp",
|
||||
"size": 1216077,
|
||||
"sha256": "b9e623839a8cf71d929a860b53a6c4c82cbfde551151912e1aba0347532be965"
|
||||
"size": 1243384,
|
||||
"sha256": "ae0d36d5f2dda70c39bfe8be474108b92f0a5a80457bc8c07d36736b09b0310d"
|
||||
},
|
||||
"members": [
|
||||
{
|
||||
@@ -92,8 +92,8 @@
|
||||
{
|
||||
"name": "RetroBIOS/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 1162452,
|
||||
"sha256": "456a32e79668e125f2ebe573b549247210c59fed6389e78dc1482b57d3a36712",
|
||||
"size": 1189759,
|
||||
"sha256": "70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
@@ -166,8 +166,8 @@
|
||||
{
|
||||
"dest": ".config/modules/retrobios/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 1162452,
|
||||
"sha256": "456a32e79668e125f2ebe573b549247210c59fed6389e78dc1482b57d3a36712",
|
||||
"size": 1189759,
|
||||
"sha256": "70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"downloaded",
|
||||
"summary",
|
||||
"app",
|
||||
"record",
|
||||
"end"
|
||||
]
|
||||
}
|
||||
@@ -127,6 +128,9 @@
|
||||
{
|
||||
"$ref": "#/$defs/app"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/record"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/end"
|
||||
}
|
||||
@@ -1091,6 +1095,59 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"record": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"event",
|
||||
"platform",
|
||||
"folder",
|
||||
"path",
|
||||
"status",
|
||||
"choice",
|
||||
"date",
|
||||
"files",
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"event": {
|
||||
"const": "record"
|
||||
},
|
||||
"platform": {
|
||||
"type": "string"
|
||||
},
|
||||
"folder": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"applied",
|
||||
"written",
|
||||
"unreadable",
|
||||
"not_written"
|
||||
]
|
||||
},
|
||||
"choice": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"date": {
|
||||
"type": "string"
|
||||
},
|
||||
"files": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"end": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -37,10 +37,11 @@ KEY_TYPES: dict[str, dict] = {
|
||||
**dict.fromkeys((
|
||||
"installer", "os", "os_name", "stage", "reason", "platform", "message",
|
||||
"folder", "dest", "system", "name", "bios_dir", "path", "member",
|
||||
"list_version", "installed_version", "board", "core",
|
||||
"list_version", "installed_version", "board", "core", "date",
|
||||
), _TEXT),
|
||||
"mode": {"enum": ["install", "check", "dry-run", "list"]},
|
||||
"state": {"enum": ["present", "wrong_hash", "missing"]},
|
||||
"status": {"enum": ["applied", "written", "unreadable", "not_written"]},
|
||||
**dict.fromkeys((
|
||||
"files", "files_done", "files_total", "bytes", "bytes_done", "bytes_total",
|
||||
"bytes_local", "bytes_network", "size", "present", "missing", "wrong_hash",
|
||||
@@ -48,7 +49,7 @@ KEY_TYPES: dict[str, dict] = {
|
||||
"pending", "code", "games", "recognised", "not_games",
|
||||
), _COUNT),
|
||||
**dict.fromkeys(("truncated", "ok", "interrupted", "required_only", "applied", "info"), _FLAG),
|
||||
**dict.fromkeys(("systems", "other_drives", "dests", "written"), _NAMES),
|
||||
**dict.fromkeys(("systems", "other_drives", "dests", "written", "choice"), _NAMES),
|
||||
**dict.fromkeys(("platforms", "failed", "skipped", "left_out", "kept", "found",
|
||||
"fallback"), _RECORDS),
|
||||
}
|
||||
|
||||
Vendored
+41
@@ -1064,3 +1064,44 @@
|
||||
|
||||
|
||||
^[[0;1;35mA/Enter^[[0m Back
|
||||
=== plan-record
|
||||
^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m
|
||||
|
||||
^[[0;1m ^[[0;1;35m1^[[0;1m file to download, ^[[0;1;35m16.0 KB^[[0m
|
||||
BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m
|
||||
For nintendo-gba.
|
||||
|
||||
^[[0;7;35m > Install^[[0m
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy the BIOS files from an old card or a folder
|
||||
Change where
|
||||
Details
|
||||
Options
|
||||
Quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
^[[0;1;35mA/Enter^[[0m Install ^[[0;2mB/Esc^[[0m Back ^[[0;2mY/o^[[0m Options ^[[0;2m?^[[0m Help
|
||||
Vendored
+13
@@ -336,3 +336,16 @@
|
||||
^[[0;2m Shortened to fit this window.^[[0m
|
||||
|
||||
^[[0;1;35mA^[[0m Back
|
||||
=== plan-record
|
||||
^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m
|
||||
|
||||
^[[0;1m ^[[0;1;35m1^[[0;1m file to download, ^[[0;1;35m16.0 KB^[[0m
|
||||
BIOS folder:
|
||||
^[[0;1m~/.config/retroarch/system^[[0m
|
||||
|
||||
^[[0;7;35m > Install^[[0m
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy from an old card or a folder
|
||||
^[[0;2m 1-4 of 8^[[0m
|
||||
^[[0;1;35mA^[[0m Install ^[[0;2mB^[[0m Back ^[[0;2mY^[[0m Options ^[[0;2m?^[[0m Help
|
||||
Vendored
+16
@@ -414,3 +414,19 @@
|
||||
|
||||
|
||||
^[[0;1;35mA^[[0m Back
|
||||
=== plan-record
|
||||
^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m
|
||||
|
||||
^[[0;1m ^[[0;1;35m1^[[0;1m file to download, ^[[0;1;35m16.0 KB^[[0m
|
||||
BIOS folder:
|
||||
^[[0;1m~/.config/retroarch/system^[[0m
|
||||
For nintendo-gba.
|
||||
^[[0;7;35m > Install^[[0m
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy from an old card or a folder
|
||||
Change where
|
||||
Details
|
||||
Options
|
||||
Quit
|
||||
^[[0;1;35mA^[[0m Install ^[[0;2mB^[[0m Back ^[[0;2mY^[[0m Options ^[[0;2m?^[[0m Help
|
||||
Vendored
+25
@@ -648,3 +648,28 @@
|
||||
|
||||
|
||||
^[[0;1;35mA/Enter^[[0m Back
|
||||
=== plan-record
|
||||
^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m
|
||||
|
||||
^[[0;1m ^[[0;1;35m1^[[0;1m file to download, ^[[0;1;35m16.0 KB^[[0m
|
||||
BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m
|
||||
For nintendo-gba.
|
||||
|
||||
^[[0;7;35m > Install^[[0m
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy the BIOS files from an old card or a folder
|
||||
Change where
|
||||
Details
|
||||
Options
|
||||
Quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
^[[0;1;35mA/Enter^[[0m Install ^[[0;2mB/Esc^[[0m Back ^[[0;2mY/o^[[0m Options ^[[0;2m?^[[0m Help
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
RetroBIOS: RetroArch
|
||||
|
||||
1 file to download, 16.0 KB
|
||||
BIOS folder: ~/.config/retroarch/system
|
||||
For nintendo-gba.
|
||||
|
||||
> Install
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy the BIOS files from an old card or a folder
|
||||
Change where
|
||||
Details
|
||||
Options
|
||||
Quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
A/Enter Install B/Esc Back Y/o Options ? Help
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
RetroBIOS: RetroArch
|
||||
|
||||
1 file to download, 16.0 KB
|
||||
BIOS folder:
|
||||
~/.config/retroarch/system
|
||||
|
||||
> Install
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy from an old card or a folder
|
||||
1-4 of 8
|
||||
A Install B Back Y Options ? Help
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
RetroBIOS: RetroArch
|
||||
|
||||
1 file to download, 16.0 KB
|
||||
BIOS folder:
|
||||
~/.config/retroarch/system
|
||||
For nintendo-gba.
|
||||
> Install
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy from an old card or a folder
|
||||
Change where
|
||||
Details
|
||||
Options
|
||||
Quit
|
||||
A Install B Back Y Options ? Help
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
RetroBIOS: RetroArch
|
||||
|
||||
1 file to download, 16.0 KB
|
||||
BIOS folder: ~/.config/retroarch/system
|
||||
For nintendo-gba.
|
||||
|
||||
> Install
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy the BIOS files from an old card or a folder
|
||||
Change where
|
||||
Details
|
||||
Options
|
||||
Quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
A/Enter Install B/Esc Back Y/o Options ? Help
|
||||
Vendored
+54
@@ -1183,3 +1183,57 @@
|
||||
|
||||
|
||||
A Back
|
||||
=== plan-record 40x12
|
||||
RetroBIOS: RetroArch
|
||||
|
||||
1 file to download, 16.0 KB
|
||||
BIOS folder:
|
||||
~/.config/retroarch/system
|
||||
|
||||
> Install
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy from an old card or a folder
|
||||
1-4 of 8
|
||||
A Install B Back Y Options
|
||||
=== plan-record 40x15
|
||||
RetroBIOS: RetroArch
|
||||
|
||||
1 file to download, 16.0 KB
|
||||
BIOS folder:
|
||||
~/.config/retroarch/system
|
||||
For nintendo-gba.
|
||||
> Install
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy from an old card or a folder
|
||||
Change where
|
||||
Details
|
||||
Options
|
||||
Quit
|
||||
A Install B Back Y Options
|
||||
=== plan-record 80x24
|
||||
RetroBIOS: RetroArch
|
||||
|
||||
1 file to download, 16.0 KB
|
||||
BIOS folder: ~/.config/retroarch/system
|
||||
For nintendo-gba.
|
||||
|
||||
> Install
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy the BIOS files from an old card or a folder
|
||||
Change where
|
||||
Details
|
||||
Options
|
||||
Quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
A Install B Back Y Options
|
||||
+54
@@ -1183,3 +1183,57 @@
|
||||
|
||||
|
||||
A Back
|
||||
=== plan-record 40x12
|
||||
RetroBIOS: RetroArch
|
||||
|
||||
1 file to download, 16.0 KB
|
||||
BIOS folder:
|
||||
~/.config/retroarch/system
|
||||
|
||||
> Install
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy from an old card or a folder
|
||||
1-4 of 8
|
||||
A Install B Back Y Options
|
||||
=== plan-record 40x15
|
||||
RetroBIOS: RetroArch
|
||||
|
||||
1 file to download, 16.0 KB
|
||||
BIOS folder:
|
||||
~/.config/retroarch/system
|
||||
For nintendo-gba.
|
||||
> Install
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy from an old card or a folder
|
||||
Change where
|
||||
Details
|
||||
Options
|
||||
Quit
|
||||
A Install B Back Y Options
|
||||
=== plan-record 80x24
|
||||
RetroBIOS: RetroArch
|
||||
|
||||
1 file to download, 16.0 KB
|
||||
BIOS folder: ~/.config/retroarch/system
|
||||
For nintendo-gba.
|
||||
|
||||
> Install
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy the BIOS files from an old card or a folder
|
||||
Change where
|
||||
Details
|
||||
Options
|
||||
Quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
A Install B Back Y Options
|
||||
+54
@@ -1183,3 +1183,57 @@
|
||||
|
||||
|
||||
A Back
|
||||
=== plan-record 40x12
|
||||
RetroBIOS: RetroArch
|
||||
|
||||
1 file to download, 16.0 KB
|
||||
BIOS folder:
|
||||
~/.config/retroarch/system
|
||||
|
||||
> Install
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy from an old card or a folder
|
||||
1-4 of 8
|
||||
A Install B Back Y Options
|
||||
=== plan-record 40x15
|
||||
RetroBIOS: RetroArch
|
||||
|
||||
1 file to download, 16.0 KB
|
||||
BIOS folder:
|
||||
~/.config/retroarch/system
|
||||
For nintendo-gba.
|
||||
> Install
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy from an old card or a folder
|
||||
Change where
|
||||
Details
|
||||
Options
|
||||
Quit
|
||||
A Install B Back Y Options
|
||||
=== plan-record 80x24
|
||||
RetroBIOS: RetroArch
|
||||
|
||||
1 file to download, 16.0 KB
|
||||
BIOS folder: ~/.config/retroarch/system
|
||||
For nintendo-gba.
|
||||
|
||||
> Install
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy the BIOS files from an old card or a folder
|
||||
Change where
|
||||
Details
|
||||
Options
|
||||
Quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
A Install B Back Y Options
|
||||
Vendored
+54
@@ -1348,3 +1348,57 @@
|
||||
|
||||
|
||||
Enter Back
|
||||
=== plan-record 40x12
|
||||
RetroBIOS: RetroArch
|
||||
|
||||
1 file to download, 16.0 KB
|
||||
BIOS folder:
|
||||
~/.config/retroarch/system
|
||||
|
||||
> Install
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy from an old card or a folder
|
||||
1-4 of 8
|
||||
Enter Install Esc Back o Options
|
||||
=== plan-record 40x15
|
||||
RetroBIOS: RetroArch
|
||||
|
||||
1 file to download, 16.0 KB
|
||||
BIOS folder:
|
||||
~/.config/retroarch/system
|
||||
For nintendo-gba.
|
||||
> Install
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy from an old card or a folder
|
||||
Change where
|
||||
Details
|
||||
Options
|
||||
Quit
|
||||
Enter Install Esc Back o Options
|
||||
=== plan-record 80x24
|
||||
RetroBIOS: RetroArch
|
||||
|
||||
1 file to download, 16.0 KB
|
||||
BIOS folder: ~/.config/retroarch/system
|
||||
For nintendo-gba.
|
||||
|
||||
> Install
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy the BIOS files from an old card or a folder
|
||||
Change where
|
||||
Details
|
||||
Options
|
||||
Quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Enter Install Esc Back o Options ? Help
|
||||
Vendored
+54
@@ -1286,3 +1286,57 @@
|
||||
|
||||
|
||||
A Back
|
||||
=== plan-record 40x12
|
||||
RetroBIOS: RetroArch
|
||||
|
||||
1 file to download, 16.0 KB
|
||||
BIOS folder:
|
||||
~/.config/retroarch/system
|
||||
|
||||
> Install
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy from an old card or a folder
|
||||
1-4 of 8
|
||||
A Install R2 Back L2 Options
|
||||
=== plan-record 40x15
|
||||
RetroBIOS: RetroArch
|
||||
|
||||
1 file to download, 16.0 KB
|
||||
BIOS folder:
|
||||
~/.config/retroarch/system
|
||||
For nintendo-gba.
|
||||
> Install
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy from an old card or a folder
|
||||
Change where
|
||||
Details
|
||||
Options
|
||||
Quit
|
||||
A Install R2 Back L2 Options
|
||||
=== plan-record 80x24
|
||||
RetroBIOS: RetroArch
|
||||
|
||||
1 file to download, 16.0 KB
|
||||
BIOS folder: ~/.config/retroarch/system
|
||||
For nintendo-gba.
|
||||
|
||||
> Install
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy the BIOS files from an old card or a folder
|
||||
Change where
|
||||
Details
|
||||
Options
|
||||
Quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
A Install R2 Back L2 Options
|
||||
Vendored
+54
@@ -1402,3 +1402,57 @@
|
||||
|
||||
|
||||
Start Back
|
||||
=== plan-record 40x12
|
||||
RetroBIOS: RetroArch
|
||||
|
||||
1 file to download, 16.0 KB
|
||||
BIOS folder:
|
||||
~/.config/retroarch/system
|
||||
|
||||
> Install
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy from an old card or a folder
|
||||
1-4 of 8
|
||||
Start Install B Back
|
||||
=== plan-record 40x15
|
||||
RetroBIOS: RetroArch
|
||||
|
||||
1 file to download, 16.0 KB
|
||||
BIOS folder:
|
||||
~/.config/retroarch/system
|
||||
For nintendo-gba.
|
||||
> Install
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy from an old card or a folder
|
||||
Change where
|
||||
Details
|
||||
Options
|
||||
Quit
|
||||
Start Install B Back
|
||||
=== plan-record 80x24
|
||||
RetroBIOS: RetroArch
|
||||
|
||||
1 file to download, 16.0 KB
|
||||
BIOS folder: ~/.config/retroarch/system
|
||||
For nintendo-gba.
|
||||
|
||||
> Install
|
||||
Same choice as on 3 Nov: yes
|
||||
Choose systems
|
||||
Copy the BIOS files from an old card or a folder
|
||||
Change where
|
||||
Details
|
||||
Options
|
||||
Quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Start Install B Back
|
||||
+16
-2
@@ -6954,10 +6954,19 @@ class EventsDescribeTheRun(unittest.TestCase, _SyntheticTree):
|
||||
# The last transfer: every byte came over the network.
|
||||
self.assertEqual([e for e in events if e["event"] == "transfer"][-1],
|
||||
{"event": "transfer", "bytes_local": 0, "bytes_network": 245})
|
||||
record = next(e for e in events if e["event"] == "record")
|
||||
# What the run placed, and its choice, the whole list: written
|
||||
# right before the summary.
|
||||
self.assertEqual({key: record[key] for key in ("folder", "status", "choice", "files")},
|
||||
{"folder": str(self.bios), "status": "written",
|
||||
"choice": ["--all-files"], "files": 3})
|
||||
self.assertEqual(record["path"], str(self.home / install.BACKUP_DIR
|
||||
/ f"{self.bios.name}.install.json"))
|
||||
self.assertEqual([e for e in events if e["event"] not in ("progress", "transfer")],
|
||||
self._head("install", installer) + [
|
||||
{"event": "download", "platform": "retroarch", "files": 3, "bytes": 245},
|
||||
*done,
|
||||
record,
|
||||
{"event": "summary", "mode": "install", "installed": 3, "up_to_date": 1,
|
||||
"pending": 0, "failed": [], "skipped": self.SKIPPED, "left_out": [],
|
||||
"kept": [{"folder": kept[0]["folder"], "files": 1, "bytes": 11}],
|
||||
@@ -7565,7 +7574,11 @@ class KeptFilesEndToEnd(unittest.TestCase, _SyntheticTree):
|
||||
"--no-input", "--jobs", "1", "--verbose")
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertEqual((self.bios / "b.bin").read_bytes(), self.FILES["bios/A/b.bin"])
|
||||
runs = list((self.home / install.BACKUP_DIR).iterdir())
|
||||
# The install record sits beside the run folders, a file of its own.
|
||||
held = list((self.home / install.BACKUP_DIR).iterdir())
|
||||
runs = [path for path in held if path.is_dir()]
|
||||
self.assertEqual(sorted(path.name for path in held if not path.is_dir()),
|
||||
[f"{self.bios.name}.install.json"])
|
||||
self.assertEqual(len(runs), 1)
|
||||
self.assertIn("Kept 1 file already there (11 B), which the collection's copies "
|
||||
f"replaced, in {runs[0]}", proc.stdout)
|
||||
@@ -7594,7 +7607,8 @@ class KeptFilesEndToEnd(unittest.TestCase, _SyntheticTree):
|
||||
again = _install_run(self.env, "--platform", "retroarch", "--dest", str(self.bios),
|
||||
"--no-input", "--jobs", "1")
|
||||
self.assertEqual(again.returncode, 0, again.stderr)
|
||||
self.assertEqual(len(list((self.home / install.BACKUP_DIR).iterdir())), 2)
|
||||
self.assertEqual(len([path for path in (self.home / install.BACKUP_DIR).iterdir()
|
||||
if path.is_dir()]), 2)
|
||||
|
||||
|
||||
|
||||
|
||||
+175
-34
@@ -41,15 +41,17 @@ ESC = "\x1b"
|
||||
|
||||
# (keys to start the install or reach the answer, keys in all). One system
|
||||
# is reached through None and the filter, the other device's folder typed
|
||||
# as ~/bios.
|
||||
# as ~/bios. With one setup found the screen opens on its plan: one key
|
||||
# fewer, and one more for another device, whose Esc goes back to the
|
||||
# setups. The second run of one system takes its install record's choice.
|
||||
JOURNEY_KEYS = {
|
||||
"first_install": (2, 3),
|
||||
"card_with_games": (2, 3),
|
||||
"update": (2, 3),
|
||||
"one_system": (14, 15),
|
||||
"one_system_again": (2, 3),
|
||||
"what_is_missing": (5, 6),
|
||||
"another_device": (19, 20),
|
||||
"first_install": (1, 2),
|
||||
"card_with_games": (1, 2),
|
||||
"update": (1, 2),
|
||||
"one_system": (13, 14),
|
||||
"one_system_again": (1, 2),
|
||||
"what_is_missing": (4, 5),
|
||||
"another_device": (20, 21),
|
||||
}
|
||||
|
||||
# Words each journey's screens show by default, summed over the screens it
|
||||
@@ -58,13 +60,15 @@ JOURNEY_KEYS = {
|
||||
# word: its length is the machine's. A change that adds words raises a
|
||||
# number here and says why.
|
||||
JOURNEY_WORDS = {
|
||||
"first_install": 63,
|
||||
"card_with_games": 106,
|
||||
"update": 79,
|
||||
"one_system": 193,
|
||||
"one_system_again": 68,
|
||||
"what_is_missing": 223,
|
||||
"another_device": 230,
|
||||
"first_install": 47,
|
||||
"card_with_games": 87,
|
||||
"update": 70,
|
||||
"one_system": 179,
|
||||
"one_system_again": 67,
|
||||
"what_is_missing": 207,
|
||||
# The plan of the setup found comes first now (33 words), the setups
|
||||
# one Esc away, and the last plan names Change where (2 words).
|
||||
"another_device": 265,
|
||||
}
|
||||
# Which setup? names every setup, Preview every file: lists, not prose.
|
||||
SCREEN_CEILING = 90
|
||||
@@ -75,7 +79,9 @@ LINE_TEXT = {
|
||||
"card_with_games": (4, 29),
|
||||
"update": (5, 36),
|
||||
"one_system": (4, 18),
|
||||
"what_is_missing": (3, 15),
|
||||
# After one system installed, the check counts the install record's
|
||||
# choice: one line says so, and how to count every file.
|
||||
"what_is_missing": (4, 25),
|
||||
"another_device": (4, 18),
|
||||
}
|
||||
|
||||
@@ -221,10 +227,9 @@ class Journeys(unittest.TestCase):
|
||||
repo = Repository(self)
|
||||
dest = repo.retroarch_here()
|
||||
term = self.start(repo)
|
||||
screen = term.wait("Where to install?", timeout=40)
|
||||
self.assertIn("> RetroArch", screen)
|
||||
self.press(term, "\r")
|
||||
term.wait("files to download", timeout=40)
|
||||
# The one setup found: its plan first.
|
||||
screen = term.wait("files to download", timeout=40)
|
||||
self.assertIn("Change where", screen)
|
||||
self.press(term, "\r")
|
||||
started = self.keys
|
||||
self.assertEqual(self.finish(term), 0)
|
||||
@@ -241,8 +246,6 @@ class Journeys(unittest.TestCase):
|
||||
(card / "ROMS" / "GBA").mkdir(parents=True)
|
||||
(card / "ROMS" / "GBA" / "unknown.gba").write_bytes(b"not in any catalog")
|
||||
term = self.start(repo, env={"JOURNEY_CARD": str(card)})
|
||||
term.wait("Where to install?", timeout=40)
|
||||
self.press(term, "\r")
|
||||
screen = term.wait("For the games on this card: yes", timeout=60)
|
||||
self.assertIn("For the 1 game on this card.", screen)
|
||||
self.press(term, "\r")
|
||||
@@ -265,8 +268,6 @@ class Journeys(unittest.TestCase):
|
||||
changed["size"] += 1
|
||||
repo.write_list()
|
||||
term = self.start(repo)
|
||||
term.wait("Where to install?", timeout=40)
|
||||
self.press(term, "\r")
|
||||
term.wait("1 file to download", timeout=40)
|
||||
self.press(term, "\r")
|
||||
started = self.keys
|
||||
@@ -278,8 +279,6 @@ class Journeys(unittest.TestCase):
|
||||
repo = Repository(self)
|
||||
dest = repo.retroarch_here()
|
||||
term = self.start(repo)
|
||||
term.wait("Where to install?", timeout=40)
|
||||
self.press(term, "\r")
|
||||
term.wait("files to download", timeout=40)
|
||||
for _step in range(12):
|
||||
if "> Choose systems" in term.settle():
|
||||
@@ -298,24 +297,25 @@ class Journeys(unittest.TestCase):
|
||||
self.assertEqual(self.finish(term), 0)
|
||||
self.counted("one_system", started, self.keys)
|
||||
self.assertEqual(written(dest), ["gba_bios.bin"])
|
||||
# The next release: the earlier choice is not kept, and two keys
|
||||
# install the whole list.
|
||||
# The next release changes its file: the plan opens on the same
|
||||
# choice, read from the install record, and one key installs it.
|
||||
changed = next(e for e in repo.listed["files"] if e["dest"] == "gba_bios.bin")
|
||||
changed["size"] += 1
|
||||
repo.write_list()
|
||||
term = self.start(repo)
|
||||
term.wait("Where to install?", timeout=40)
|
||||
self.press(term, "\r")
|
||||
term.wait("files to download", timeout=40)
|
||||
screen = term.wait("1 file to download", timeout=40)
|
||||
self.assertRegex(screen, r"Same choice as on \d+ [A-Z][a-z]{2}: yes")
|
||||
self.press(term, "\r")
|
||||
started = self.keys
|
||||
self.assertEqual(self.finish(term), 0)
|
||||
self.counted("one_system_again", started, self.keys)
|
||||
self.assertEqual(written(dest), sorted(e["dest"] for e in repo.listed["files"]))
|
||||
self.assertEqual(written(dest), ["gba_bios.bin"])
|
||||
self.assertEqual((dest / "gba_bios.bin").stat().st_size, changed["size"])
|
||||
|
||||
def test_what_is_missing(self):
|
||||
repo = Repository(self)
|
||||
repo.retroarch_here()
|
||||
term = self.start(repo)
|
||||
term.wait("Where to install?", timeout=40)
|
||||
self.press(term, "\r")
|
||||
term.wait("files to download", timeout=40)
|
||||
self.press(term, "o")
|
||||
term.wait("Each row is an option")
|
||||
@@ -332,6 +332,9 @@ class Journeys(unittest.TestCase):
|
||||
repo = Repository(self)
|
||||
repo.retroarch_here()
|
||||
term = self.start(repo)
|
||||
# The plan of the one setup found first: Esc goes back to the setups.
|
||||
term.wait("files to download", timeout=40)
|
||||
self.press(term, ESC)
|
||||
term.wait("Where to install?", timeout=40)
|
||||
for _step in range(12):
|
||||
if "> Another setup or folder" in term.settle():
|
||||
@@ -478,5 +481,143 @@ class LineModeJourneys(unittest.TestCase):
|
||||
self.run_line(repo, *base, "--check", "--verbose").stdout)
|
||||
|
||||
|
||||
def events(proc: subprocess.CompletedProcess, kind: str | None = None) -> list[dict]:
|
||||
said = [json.loads(line) for line in proc.stdout.splitlines() if line.startswith("{")]
|
||||
return [event for event in said if kind is None or event["event"] == kind]
|
||||
|
||||
|
||||
class InstallRecord(unittest.TestCase):
|
||||
"""What an install placed, recorded beside the BIOS folder, and its
|
||||
choice taken by the next run when nothing on the command line chose."""
|
||||
|
||||
def setUp(self):
|
||||
self.repo = Repository(self)
|
||||
self.dest = self.repo.root / "retroarch" / "system"
|
||||
self.record = self.dest.parent / install.BACKUP_DIR / "system.install.json"
|
||||
|
||||
def run_line(self, *args: str, dest: Path | None = None) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(REPO_ROOT / "install.py"), "--platform", "retroarch",
|
||||
"--dest", str(dest or self.dest), *args],
|
||||
env=self.repo.env, capture_output=True, text=True, timeout=120, check=False,
|
||||
stdin=subprocess.DEVNULL)
|
||||
|
||||
def dests(self, proc: subprocess.CompletedProcess) -> list[str]:
|
||||
return sorted(event["dest"] for event in events(proc, "file"))
|
||||
|
||||
def everything(self) -> list[str]:
|
||||
return sorted(entry["dest"] for entry in self.repo.listed["files"])
|
||||
|
||||
def test_the_record_holds_each_file_written_and_its_hash(self):
|
||||
proc = self.run_line("--system", "nintendo-gba", "--yes", "--events")
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
said = events(proc, "record")
|
||||
self.assertEqual([event["status"] for event in said], ["written"])
|
||||
self.assertEqual(said[0]["choice"], ["--system", "nintendo-gba"])
|
||||
self.assertEqual((said[0]["path"], said[0]["files"]), (str(self.record), 1))
|
||||
# Before the summary, its keys those of EVENT_KEYS.
|
||||
kinds = [event["event"] for event in events(proc)]
|
||||
self.assertEqual(kinds.index("record") + 1, kinds.index("summary"))
|
||||
self.assertEqual(list(said[0])[1:], list(install.EVENT_KEYS["record"]))
|
||||
kept = json.loads(self.record.read_text(encoding="utf-8"))
|
||||
files = kept["setups"]["retroarch"]["files"]
|
||||
self.assertEqual([item["dest"] for item in files], ["gba_bios.bin"])
|
||||
self.assertEqual(files[0]["sha1"], hashlib.sha1(
|
||||
(self.dest / "gba_bios.bin").read_bytes()).hexdigest())
|
||||
self.assertEqual((files[0]["from"], files[0]["root"]), ("download", ""))
|
||||
self.assertEqual(kept["folder"], str(self.dest))
|
||||
# Nothing of it in the BIOS folder, which some cores read whole.
|
||||
self.assertEqual(written(self.dest), ["gba_bios.bin"])
|
||||
|
||||
def test_the_next_run_takes_the_recorded_choice(self):
|
||||
self.run_line("--system", "nintendo-gba", "--yes")
|
||||
(self.dest / "gba_bios.bin").unlink()
|
||||
proc = self.run_line("--yes", "--events")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
kinds = [event["event"] for event in events(proc)]
|
||||
self.assertLess(kinds.index("record"), kinds.index("choices"))
|
||||
applied = events(proc, "record")[0]
|
||||
self.assertEqual((applied["status"], applied["choice"]),
|
||||
("applied", ["--system", "nintendo-gba"]))
|
||||
self.assertEqual(self.dests(proc), ["gba_bios.bin"])
|
||||
self.assertEqual(written(self.dest), ["gba_bios.bin"])
|
||||
(self.dest / "gba_bios.bin").unlink()
|
||||
proc = self.run_line("--yes")
|
||||
self.assertRegex(proc.stdout, r"Installing 1 file \(.*\) for RetroArch, same choice "
|
||||
r"as on \d+ [A-Z][a-z]{2}\.")
|
||||
|
||||
def test_the_command_line_replaces_the_record(self):
|
||||
self.run_line("--system", "nintendo-gba", "--yes")
|
||||
self.assertEqual(self.dests(self.run_line("--system", "sega-megadrive", "--check",
|
||||
"--events")),
|
||||
sorted(e["dest"] for e in self.repo.listed["files"]
|
||||
if "sega-megadrive" in e.get("systems", [])))
|
||||
whole = self.run_line("--all-files", "--check", "--events")
|
||||
self.assertEqual(self.dests(whole), self.everything())
|
||||
self.assertEqual(events(whole, "record"), [])
|
||||
|
||||
def test_a_record_that_cannot_be_read_is_said_once_and_ignored(self):
|
||||
self.dest.mkdir(parents=True)
|
||||
self.record.parent.mkdir()
|
||||
self.record.write_text("{not json", encoding="utf-8")
|
||||
proc = self.run_line("--yes", "--events")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
said = events(proc, "record")
|
||||
self.assertEqual([event["status"] for event in said], ["unreadable", "written"])
|
||||
self.assertIn("is damaged, so it is ignored.", said[0]["message"])
|
||||
# Said once to a person (standard error under --events), once as an event.
|
||||
self.assertEqual(proc.stderr.count("so it is ignored"), 1)
|
||||
self.assertEqual(self.dests(proc), self.everything())
|
||||
# Replaced by a record that reads.
|
||||
self.assertEqual(json.loads(self.record.read_text())["schema"], install.RECORD_SCHEMA)
|
||||
for text in ('{"schema": 99, "setups": {}}', '{"schema": 1, "setups": {"retroarch": 3}}',
|
||||
"\xff\xfe"):
|
||||
with self.subTest(record=text):
|
||||
self.record.write_bytes(text.encode("latin-1"))
|
||||
proc = self.run_line("--check", "--events")
|
||||
self.assertEqual(events(proc, "record")[0]["status"], "unreadable")
|
||||
self.assertEqual(self.dests(proc), self.everything())
|
||||
|
||||
def test_a_record_for_another_folder_is_not_applied(self):
|
||||
self.run_line("--system", "nintendo-gba", "--yes")
|
||||
# A sibling folder shares the holder, never the record.
|
||||
other = self.dest.parent / "bios"
|
||||
proc = self.run_line("--check", "--events", dest=other)
|
||||
self.assertEqual(events(proc, "record"), [])
|
||||
self.assertEqual(self.dests(proc), self.everything())
|
||||
# The record copied under the sibling's name still names the first.
|
||||
shutil.copy(self.record, self.record.with_name("bios.install.json"))
|
||||
proc = self.run_line("--check", "--events", dest=other)
|
||||
self.assertEqual(events(proc, "record"), [])
|
||||
self.assertEqual(self.dests(proc), self.everything())
|
||||
# The record of another setup in the same folder is not this one's.
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(REPO_ROOT / "install.py"), "--platform", "batocera",
|
||||
"--dest", str(self.dest), "--check", "--events"],
|
||||
env=self.repo.env, capture_output=True, text=True, timeout=120, check=False,
|
||||
stdin=subprocess.DEVNULL)
|
||||
self.assertEqual(events(proc, "record"), [])
|
||||
self.assertEqual(self.dests(proc), self.everything())
|
||||
|
||||
def test_line_mode_chooses_what_the_screen_chooses(self):
|
||||
"""The plan's choice from the record is the same set of files on the
|
||||
screen and in line mode: the screen's options, given to a run, and a
|
||||
run given none."""
|
||||
self.run_line("--system", "nintendo-gba", "--yes")
|
||||
(self.dest / "gba_bios.bin").unlink()
|
||||
state = install.TuiState()
|
||||
state.platform, state.dest = "retroarch", self.dest
|
||||
install._tui_record_load(state)
|
||||
self.assertTrue(install.tui_record_kept(state))
|
||||
flags = [flag if value is None else f"{flag}={value}"
|
||||
for flag, value in install.tui_flags(state)]
|
||||
screen = self.run_line(*flags, "--dry-run", "--events")
|
||||
line = self.run_line("--dry-run", "--events")
|
||||
self.assertEqual(self.dests(screen), ["gba_bios.bin"])
|
||||
self.assertEqual(self.dests(line), self.dests(screen))
|
||||
self.assertEqual(events(line, "check")[0]["bytes_needed"],
|
||||
events(screen, "check")[0]["bytes_needed"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+194
-44
@@ -378,6 +378,14 @@ def screen_text(lines) -> str:
|
||||
return re.sub(r"(?<=[/\\]) ", "", " ".join(" ".join(lines).split()))
|
||||
|
||||
|
||||
def listed(state: install.TuiState) -> install.TuiState:
|
||||
"""The setups found, as Back shows them from the plan the screen opens
|
||||
on when it found one setup."""
|
||||
if state.opened_on_plan and state.screen != "setup":
|
||||
install.tui_update(state, Key("esc"))
|
||||
return state
|
||||
|
||||
|
||||
def fresh_state() -> install.TuiState:
|
||||
state = install.TuiState(os_type="linux", home=HOME)
|
||||
state.setups = [("retroarch", SYSTEM_DIR, "RetroArch"),
|
||||
@@ -761,7 +769,9 @@ class Flow(unittest.TestCase):
|
||||
mock.patch.object(install, "detect_platforms", return_value=[]), \
|
||||
mock.patch.object(install, "detect_frontends", return_value=[]):
|
||||
state, effects = install.tui_start(_args(), "linux")
|
||||
self.assertEqual((state.screen, effects), ("setup", []))
|
||||
# The one setup found: its plan first, the list one Back away.
|
||||
self.assertEqual((state.screen, effects), ("checking", [("fetch", "minui")]))
|
||||
self.assertEqual(listed(state).screen, "setup")
|
||||
label = f"MinUI SD card ({card.name})"
|
||||
self.assertEqual(state.setups, [("minui", card, label)])
|
||||
self.assertEqual([row.label for row in install.tui_rows(state)],
|
||||
@@ -1032,9 +1042,55 @@ def golden_states() -> dict:
|
||||
told = golden_states_result()
|
||||
told.stack.append("details")
|
||||
states["result-details"] = told
|
||||
states["plan-record"] = record_state()
|
||||
return states
|
||||
|
||||
|
||||
def record_setup(choice: list[str] | None = None) -> dict:
|
||||
"""A record kept on 3 November of this year: the plan says 3 Nov in
|
||||
any year it runs."""
|
||||
year = time.localtime().tm_year
|
||||
return {"written": f"{year}-11-03T12:00:00Z", "list": {"version": "v1", "generated": ""},
|
||||
"choice": ["--system", "nintendo-gba"] if choice is None else choice,
|
||||
"files": [{"dest": "gba_bios.bin", "root": "", "sha1": "0" * 40, "size": 16384,
|
||||
"from": "download"}]}
|
||||
|
||||
|
||||
def record_state(setup: dict | None = None) -> install.TuiState:
|
||||
"""The plan of a RetroArch folder whose record kept Game Boy Advance,
|
||||
its file changed by a release since, opened on the one setup found."""
|
||||
setup = record_setup() if setup is None else setup
|
||||
state = fresh_state()
|
||||
state.setups = state.setups[:1]
|
||||
manifest = json.loads(json.dumps(MANIFEST))
|
||||
manifest["files"][2]["sha1"] = "1" * 40
|
||||
with mock.patch.object(install, "setup_record",
|
||||
return_value=(setup, SYSTEM_DIR.parent / "x", "")), \
|
||||
mock.patch.object(install.Path, "exists", return_value=True), \
|
||||
mock.patch.object(install, "found_setups",
|
||||
return_value=(state.setups, set())), \
|
||||
mock.patch.object(install, "detect_frontends", return_value=[]), \
|
||||
mock.patch.object(install, "_mounted_cards", return_value=[]), \
|
||||
mock.patch.object(install, "_DETECTION_REFUSED", []), \
|
||||
mock.patch.object(install.Path, "home", return_value=HOME):
|
||||
state, effects = install.tui_start(_args(), "linux")
|
||||
assert effects == [("fetch", "retroarch")], effects
|
||||
# Drawn as the other screens are, whatever the shell running the tests.
|
||||
state.home, state.pad = HOME, "both"
|
||||
effects = install.tui_receive(state, ("manifest", "retroarch", manifest))
|
||||
assert effects == [("run", "check")], effects
|
||||
gba = [event for event in FILES if event["dest"] == "gba_bios.bin"]
|
||||
systems = [event for event in SYSTEMS if event["system"] == "nintendo-gba"]
|
||||
check = {**CHECK, "files": 1, "present": 0, "missing": 1, "wrong_hash": 0,
|
||||
"bytes_needed": 16384, "not_installable": 0}
|
||||
for event in [*gba, *systems, check]:
|
||||
install.tui_receive(state, ("event", state.run_id, event))
|
||||
with mock.patch.object(install, "_tui_folder_facts", return_value={
|
||||
"free": 50 * 1024 ** 3, "writable": True, "cores": ["pcsx_rearmed"]}):
|
||||
install.tui_receive(state, ("ended", state.run_id, 1, []))
|
||||
return state
|
||||
|
||||
|
||||
def golden_states_result() -> install.TuiState:
|
||||
"""The result golden_states draws, a file failed and one kept."""
|
||||
result = checked_state()
|
||||
@@ -1075,6 +1131,102 @@ def result_states() -> dict:
|
||||
}
|
||||
|
||||
|
||||
class RecordedPlan(unittest.TestCase):
|
||||
"""The plan of a folder with an install record: opened on the one setup
|
||||
found, on the recorded choice, one press from installing it again."""
|
||||
|
||||
def row(self, state: install.TuiState, action: str) -> install.TuiRow:
|
||||
return next(row for row in install.tui_rows(state) if row.action == action)
|
||||
|
||||
def test_opens_on_the_plan_of_the_one_setup_found(self):
|
||||
state = record_state()
|
||||
self.assertTrue(state.opened_on_plan)
|
||||
self.assertEqual(state.stack, ["setup", "plan"])
|
||||
self.assertEqual(install.tui_rows(state)[0].action, "install")
|
||||
press(state, "end", "up", "up", "up")
|
||||
self.assertEqual(install.tui_rows(state)[state.focus["plan"]].label, "Change where")
|
||||
press(state, "enter")
|
||||
self.assertEqual(state.screen, "setup")
|
||||
|
||||
def test_several_setups_open_the_list(self):
|
||||
with mock.patch.object(install, "found_setups", return_value=(
|
||||
fresh_state().setups, set())), \
|
||||
mock.patch.object(install, "detect_frontends", return_value=[]), \
|
||||
mock.patch.object(install, "_mounted_cards", return_value=[]), \
|
||||
mock.patch.object(install, "_DETECTION_REFUSED", []):
|
||||
state, effects = install.tui_start(_args(), "linux")
|
||||
self.assertEqual((state.screen, effects, state.opened_on_plan), ("setup", [], False))
|
||||
|
||||
def test_one_press_installs_the_recorded_choice(self):
|
||||
state = record_state()
|
||||
self.assertEqual(self.row(state, "record").label, "Same choice as on 3 Nov: yes")
|
||||
self.assertIn("For nintendo-gba.", screen_text(
|
||||
install.tui_view(state, 80, 24)))
|
||||
self.assertEqual(press(state, "enter"), [("run", "install")])
|
||||
argv = install.tui_argv(state, "install", "py", "install.py")
|
||||
self.assertIn("--system=nintendo-gba", argv)
|
||||
self.assertNotIn("--all-files", argv)
|
||||
|
||||
def test_no_takes_the_whole_list_and_yes_brings_it_back(self):
|
||||
state = record_state()
|
||||
state.focus["plan"] = install.tui_rows(state).index(self.row(state, "record"))
|
||||
with mock.patch.object(install, "games_probe", return_value=None):
|
||||
effects = press(state, "enter")
|
||||
self.assertEqual(effects, [("run", "check")])
|
||||
flags = install.tui_flags(state)
|
||||
self.assertIn(("--all-files", None), flags)
|
||||
self.assertFalse([flag for flag in flags if flag[0] == "--system"])
|
||||
self.assertFalse(install.tui_record_kept(state))
|
||||
for event in [*FILES, *SYSTEMS, CHECK]:
|
||||
install.tui_receive(state, ("event", state.run_id, event))
|
||||
with mock.patch.object(install, "_tui_folder_facts", return_value={
|
||||
"free": 50 * 1024 ** 3, "writable": True}):
|
||||
install.tui_receive(state, ("ended", state.run_id, 1, []))
|
||||
self.assertEqual(self.row(state, "record").label, "Same choice as on 3 Nov: no")
|
||||
self.assertEqual(install.tui_rows(state)[state.focus["plan"]].action, "record")
|
||||
self.assertEqual(press(state, "enter"), [("run", "check")])
|
||||
self.assertIn(("--system", "nintendo-gba"), install.tui_flags(state))
|
||||
self.assertTrue(install.tui_record_kept(state))
|
||||
|
||||
def test_the_command_line_choice_wins(self):
|
||||
with mock.patch.object(install, "setup_record",
|
||||
return_value=(record_setup(), SYSTEM_DIR, "")), \
|
||||
mock.patch.object(install.Path, "exists", return_value=True):
|
||||
state, _effects = install.tui_start(
|
||||
_args(platform="retroarch", dest=str(SYSTEM_DIR), system=["sony-playstation"]),
|
||||
"linux", detect=False)
|
||||
self.assertEqual(install._tui_system_flags(state), [("--system", "sony-playstation")])
|
||||
self.assertFalse(install.tui_record_kept(state))
|
||||
self.assertEqual(install._record_rows(state), [])
|
||||
|
||||
def test_a_record_that_cannot_be_read_is_said_once(self):
|
||||
problem = "The install record /x/system.install.json is damaged"
|
||||
install._RECORD_SAID.discard("/x/system.install.json")
|
||||
with mock.patch.object(install, "setup_record",
|
||||
return_value=(None, Path("/x/system.install.json"), problem)), \
|
||||
mock.patch.object(install.Path, "exists", return_value=True):
|
||||
state, _effects = install.tui_start(
|
||||
_args(platform="retroarch", dest=str(SYSTEM_DIR)), "linux", detect=False)
|
||||
self.assertEqual(str(state.status), f"{problem}, so it is ignored.")
|
||||
state.status, state.record_for = "", None
|
||||
install._tui_record_load(state)
|
||||
self.assertEqual(state.status, "")
|
||||
self.assertIsNone(state.record)
|
||||
self.assertNotIn("--all-files", str(install.tui_flags(state)))
|
||||
|
||||
def test_the_install_row_says_the_size_the_headline_says(self):
|
||||
"""14930 files to download, 6.8 GB stood above Install 6.6 GB: the
|
||||
data files counted in one, the copies in the other."""
|
||||
big = 2 * 1024 ** 3
|
||||
state = checked_state()
|
||||
state.files[0] = {**state.files[0], "size": big}
|
||||
state.check = {**state.check, "bytes_needed": state.check["bytes_needed"] + big,
|
||||
"data_missing": 3, "data_bytes": 300 * 1024 ** 2}
|
||||
headline = str(install._plan_lines(state.check, state.dest, state)[0])
|
||||
size = headline.rsplit(", ", 1)[1]
|
||||
self.assertEqual(install.tui_rows(state)[0].label, f"Install {size}")
|
||||
|
||||
|
||||
class Views(unittest.TestCase):
|
||||
"""Every screen as text, at the sizes a handheld and a desktop show."""
|
||||
|
||||
@@ -2387,6 +2539,9 @@ class CardsSpaceAndConfirm(unittest.TestCase):
|
||||
mock.patch.object(install, "detect_platforms", return_value=[]), \
|
||||
mock.patch.object(install, "detect_frontends", return_value=[]):
|
||||
state, effects = install.tui_start(_args(), "linux")
|
||||
self.assertEqual(effects, [("fetch", "retroarch")])
|
||||
self.assertEqual(state.dest, root / "MUOS" / "bios")
|
||||
listed(state)
|
||||
label = f"muOS SD card ({root.name})"
|
||||
self.assertEqual(state.setups, [("retroarch", root / "MUOS" / "bios", label)])
|
||||
text = screen_text(install.tui_view(state, 120, 40))
|
||||
@@ -4527,6 +4682,14 @@ class WindowsHasNoPosixModules(unittest.TestCase):
|
||||
# --- End to end, on a pseudo-terminal -------------------------------------
|
||||
|
||||
|
||||
def on_setups(term: Pty) -> str:
|
||||
"""The setups found, one Back from the plan the screen opens on when it
|
||||
found one setup: the first screen as it was before."""
|
||||
term.wait("> Install")
|
||||
term.send("\x1b")
|
||||
return term.wait("Where to install?")
|
||||
|
||||
|
||||
class Pty:
|
||||
"""install.py on a pseudo-terminal, its screen read back through pyte."""
|
||||
|
||||
@@ -4925,14 +5088,13 @@ class EndToEnd(unittest.TestCase):
|
||||
self.assertAppOnCard(root, "KNULLI")
|
||||
self.assertIn("--add-app", term.after())
|
||||
|
||||
def test_three_presses_install_everything(self):
|
||||
def test_two_presses_install_everything(self):
|
||||
repo = self.repo()
|
||||
term = self.start()
|
||||
screen = term.wait("Where to install?")
|
||||
self.assertIn("> RetroArch", screen)
|
||||
term.send("\r")
|
||||
# The one setup found here: its plan first.
|
||||
screen = term.wait("files to download")
|
||||
self.assertIn("5 files to download, 30 B", screen)
|
||||
self.assertIn("Change where", screen)
|
||||
self.assertIn("> Install", screen)
|
||||
term.send("\r")
|
||||
term.wait("5 files installed.")
|
||||
@@ -4979,8 +5141,6 @@ class EndToEnd(unittest.TestCase):
|
||||
self.assertIn("6 files installed", proc.stdout)
|
||||
else:
|
||||
term = self.start()
|
||||
term.wait("Where to install?")
|
||||
term.send("\r")
|
||||
screen = term.wait("files to download")
|
||||
self.assertIn("6 files to download", screen)
|
||||
# The second folder the run writes to, named on the
|
||||
@@ -5159,7 +5319,7 @@ class EndToEnd(unittest.TestCase):
|
||||
def test_ctrl_c_on_the_first_screen(self):
|
||||
self.repo()
|
||||
term = self.start()
|
||||
term.wait("Where to install?")
|
||||
term.wait("> Install")
|
||||
term.send("\x03")
|
||||
self.assertEqual(term.close(), 130)
|
||||
self.assertIn(b"\x1b[?2004l\x1b[0m\x1b[?25h\x1b[?1049l", term.raw)
|
||||
@@ -5169,7 +5329,7 @@ class EndToEnd(unittest.TestCase):
|
||||
|
||||
self.repo()
|
||||
term = self.start()
|
||||
term.wait("Where to install?")
|
||||
term.wait("> Install")
|
||||
mark = len(term.raw)
|
||||
os.kill(term.pid, signal.SIGTSTP)
|
||||
end = time.monotonic() + 10
|
||||
@@ -5182,7 +5342,7 @@ class EndToEnd(unittest.TestCase):
|
||||
self.assertIn(b"\x1b[?25h\x1b[?1049l", term.raw[mark:])
|
||||
mark = len(term.raw)
|
||||
os.kill(term.pid, signal.SIGCONT)
|
||||
term.wait("A/Enter Continue")
|
||||
term.wait("A/Enter Install")
|
||||
end = time.monotonic() + 5
|
||||
while b"\x1b[?1049h" not in term.raw[mark:] and time.monotonic() < end:
|
||||
term._pump(0.05)
|
||||
@@ -5194,11 +5354,11 @@ class EndToEnd(unittest.TestCase):
|
||||
"""The terminal gone, the farewell has nowhere to go: the code is
|
||||
129 (SIGHUP), not 120 from the flush failing at exit."""
|
||||
self.repo()
|
||||
for screen_name, keys_first in (("Where to install?", ()),
|
||||
("files to download", ("\r",))):
|
||||
for screen_name, keys_first in (("files to download", ()),
|
||||
("Where to install?", ("\x1b",))):
|
||||
with self.subTest(screen=screen_name):
|
||||
term = self.start()
|
||||
term.wait("Where to install?")
|
||||
term.wait("files to download")
|
||||
term.send(*keys_first)
|
||||
term.wait(screen_name)
|
||||
os.close(term.fd)
|
||||
@@ -5247,12 +5407,12 @@ class EndToEnd(unittest.TestCase):
|
||||
self.repo()
|
||||
self.env[install.TUI_IDLE_ENV] = "1"
|
||||
term = self.start()
|
||||
term.wait("Where to install?")
|
||||
term.wait("> Install")
|
||||
self.assertEqual(term.close(), install.QUIT_CODE)
|
||||
self.assertIn("No button pressed, closing.", term.after())
|
||||
# A key pressed, the screen stays: the person is there.
|
||||
term = self.start()
|
||||
term.wait("Where to install?")
|
||||
term.wait("> Install")
|
||||
term.send(DOWN)
|
||||
end = time.monotonic() + 2.5
|
||||
while time.monotonic() < end:
|
||||
@@ -5282,7 +5442,7 @@ class EndToEnd(unittest.TestCase):
|
||||
with self.subTest(env=env):
|
||||
self.env.update(env)
|
||||
term = self.start(cols=40, rows=12)
|
||||
term.wait("Where to install?")
|
||||
on_setups(term)
|
||||
screen = term.wait(bar)
|
||||
self.assertEqual(screen.splitlines()[-1].strip(), bar)
|
||||
term.send("q")
|
||||
@@ -5293,7 +5453,7 @@ class EndToEnd(unittest.TestCase):
|
||||
def test_resize_redraws(self):
|
||||
self.repo()
|
||||
term = self.start(cols=40, rows=15)
|
||||
term.wait("Where to install?")
|
||||
on_setups(term)
|
||||
term.resize(100, 30)
|
||||
term.send(DOWN)
|
||||
screen = term.wait("> Another setup or folder")
|
||||
@@ -5379,7 +5539,7 @@ class EndToEnd(unittest.TestCase):
|
||||
self.env.pop("NO_COLOR", None)
|
||||
self.env.update({"TERM": "xterm", **env})
|
||||
term = self.start()
|
||||
screen = term.wait("Where to install?")
|
||||
screen = on_setups(term)
|
||||
self.assertIn("> RetroArch", screen)
|
||||
screens[name] = screen
|
||||
# pyte reads the colours drawn: RetroBIOS in the accent.
|
||||
@@ -5612,7 +5772,7 @@ class TheTerminalComesBack(unittest.TestCase):
|
||||
def ended(self, how) -> Pty:
|
||||
self.repo()
|
||||
term = self.start()
|
||||
term.wait("Where to install?")
|
||||
term.wait("> Install")
|
||||
# Every way out below starts with the clicks asked for, written
|
||||
# right after the frame.
|
||||
term.settle()
|
||||
@@ -5672,7 +5832,7 @@ class TheTerminalComesBack(unittest.TestCase):
|
||||
job control it stops, and fg brings the screen back."""
|
||||
term = self.shell()
|
||||
self.typed(term, f"{shlex.quote(sys.executable)} {REPO_ROOT / 'install.py'} --tui")
|
||||
term.wait("Where to install?")
|
||||
term.wait("> Install")
|
||||
mark = len(term.raw)
|
||||
term.send("\x1a")
|
||||
end = time.monotonic() + 10
|
||||
@@ -5687,7 +5847,7 @@ class TheTerminalComesBack(unittest.TestCase):
|
||||
while b"\x1b[?1049h" not in term.raw[mark:] and time.monotonic() < end:
|
||||
term._pump(0.05)
|
||||
self.assertIn(b"\x1b[?1049h", term.raw[mark:])
|
||||
term.wait("Where to install?")
|
||||
term.wait("> Install")
|
||||
self.assertFalse(termios.tcgetattr(term.fd)[3] & termios.ECHO)
|
||||
# The clicks come back with the screen.
|
||||
term.settle()
|
||||
@@ -5709,7 +5869,7 @@ class TheTerminalComesBack(unittest.TestCase):
|
||||
term = Pty(["--tui"], self.env, program=program)
|
||||
self.addCleanup(lambda term=term: term.status is None
|
||||
and os.killpg(term.pid, 9))
|
||||
term.wait("Where to install?")
|
||||
term.wait("> Install")
|
||||
term.send("\x1a")
|
||||
screen = term.wait("Ctrl-Z is ignored")
|
||||
self.assertIn("no shell started this installer", " ".join(screen.split()))
|
||||
@@ -5765,8 +5925,6 @@ class TheTerminalComesBack(unittest.TestCase):
|
||||
self.addCleanup(lambda: term.status is None and os.killpg(term.pid, 9))
|
||||
term.wait("PROMPT$")
|
||||
self.typed(term, f"sh {REPO_ROOT / 'install.sh'} --jobs 1")
|
||||
term.wait("Where to install?")
|
||||
term.send("\r")
|
||||
term.wait("to download")
|
||||
term.send("\r")
|
||||
term.wait("Installing")
|
||||
@@ -10803,7 +10961,7 @@ class MouseOnAPty(unittest.TestCase):
|
||||
def test_taps_choose_a_row_and_press_the_bar(self):
|
||||
self.repo()
|
||||
term = self.start()
|
||||
term.wait("Where to install?")
|
||||
on_setups(term)
|
||||
term.settle()
|
||||
self.assertIn(install._MOUSE_ON.encode(), term.raw)
|
||||
row = self.line_of(term, "Another setup")
|
||||
@@ -11455,8 +11613,6 @@ class TapsOnAPty(unittest.TestCase):
|
||||
def test_taps_on_systems_tick_and_write_nothing(self):
|
||||
repo = self.repo()
|
||||
term = self.start()
|
||||
term.wait("Where to install?")
|
||||
term.send("\r")
|
||||
term.wait("> Install")
|
||||
row = self.line_of(term, "Choose systems")
|
||||
self.click(term, 20, row)
|
||||
@@ -11482,8 +11638,6 @@ class TapsOnAPty(unittest.TestCase):
|
||||
def test_the_result_is_left_by_a_tap_on_exit(self):
|
||||
self.repo()
|
||||
term = self.start()
|
||||
term.wait("Where to install?")
|
||||
term.send("\r")
|
||||
term.wait("files to download")
|
||||
row = self.line_of(term, "> Install")
|
||||
self.click(term, 20, row)
|
||||
@@ -12365,8 +12519,6 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
|
||||
def test_one_tap_on_the_bar_install_installs_nothing(self):
|
||||
repo = self.repo()
|
||||
term = self.start()
|
||||
term.wait("Where to install?")
|
||||
term.send("\r")
|
||||
term.wait("> Install")
|
||||
term.settle()
|
||||
self.click(term, *self.bar_entry(term, "Install"))
|
||||
@@ -12380,7 +12532,7 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
|
||||
def test_a_double_tap_on_continue_neither_quits_nor_installs(self):
|
||||
repo = self.repo()
|
||||
term = self.start()
|
||||
term.wait("Where to install?")
|
||||
on_setups(term)
|
||||
term.settle()
|
||||
col, line = self.bar_entry(term, "Continue")
|
||||
report = sgr(col + 1, line + 1) + sgr(col + 1, line + 1, release=True)
|
||||
@@ -12402,7 +12554,7 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
|
||||
with self.subTest(gap=gap):
|
||||
repo = self.repo()
|
||||
term = self.start()
|
||||
term.wait("Where to install?")
|
||||
on_setups(term)
|
||||
term.settle()
|
||||
time.sleep(install.TUI_TAP_SETTLE)
|
||||
col, line = self.bar_entry(term, "Continue")
|
||||
@@ -12422,8 +12574,6 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
|
||||
def test_continue_then_install_in_one_cell_stops_on_confirm(self):
|
||||
repo = self.repo()
|
||||
term = self.start()
|
||||
term.wait("Where to install?")
|
||||
term.send("\r")
|
||||
term.wait("> Install")
|
||||
row = self.line_of(term, "Choose systems")
|
||||
self.click(term, 20, row)
|
||||
@@ -12465,8 +12615,6 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
|
||||
|
||||
repo = self.repo(GameDataRepository)
|
||||
term = self.start()
|
||||
term.wait("Where to install?")
|
||||
term.send("\r")
|
||||
term.wait("> Install")
|
||||
for _step in range(8):
|
||||
if "> Choose systems" in term.settle():
|
||||
@@ -12508,8 +12656,6 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
|
||||
def test_two_taps_on_a_region_untick_it_where_it_was(self):
|
||||
self.repo()
|
||||
term = self.start()
|
||||
term.wait("Where to install?")
|
||||
term.send("\r")
|
||||
term.wait("> Install")
|
||||
term.send("o")
|
||||
term.wait("Preferred regions")
|
||||
@@ -12662,8 +12808,10 @@ class ChoiceStateInBothModes(unittest.TestCase):
|
||||
mock.patch.object(install, "detect_frontends", return_value=[]), \
|
||||
mock.patch.object(install.Path, "home", return_value=home):
|
||||
state, _effects = install.tui_start(_args(), "linux")
|
||||
text = self.screen(state)
|
||||
# Named on the first screen, its plan one Enter away.
|
||||
# Its plan first, the setups one Back away.
|
||||
self.assertEqual(state.dest, card / "bios")
|
||||
text = self.screen(listed(state))
|
||||
# Named on the setups, its plan one Enter away.
|
||||
self.assertIn(f"> ArkOS SD card ({name}", text)
|
||||
self.assertNotIn("Nothing was found here", text)
|
||||
install.tui_update(state, Key("enter"))
|
||||
@@ -12977,7 +13125,7 @@ class DriveIsACardByItsFiles(unittest.TestCase):
|
||||
mock.patch.object(install, "detect_frontends", return_value=[]), \
|
||||
mock.patch.object(install.Path, "home", return_value=self.root / "home"):
|
||||
state, _effects = install.tui_start(_args(), "linux")
|
||||
return state
|
||||
return listed(state)
|
||||
|
||||
def folder_screen(self, mounts: list) -> tuple[install.TuiState, str, list]:
|
||||
"""RetroArch chosen on the first screen: (state, Which folder? at
|
||||
@@ -13176,8 +13324,6 @@ class TheSettleRunsFromTheFrameShown(unittest.TestCase):
|
||||
env = {**self.env, "SLOW": "0.5"}
|
||||
term = Pty([], env, 80, 24, program=[sys.executable, str(wrapper)])
|
||||
self.addCleanup(lambda: term.status is None and os.kill(term.pid, 9))
|
||||
term.wait("Where to install?")
|
||||
term.send("\r")
|
||||
term.wait("> Install")
|
||||
term.settle()
|
||||
term.send("o")
|
||||
@@ -14949,6 +15095,10 @@ SCREEN_WORDS = {
|
||||
"quit": (14, 14, 12, 12), "help": (24, 26, 26, 26), "plan-games": (36, 46, 54, 54),
|
||||
"games": (36, 45, 44, 44), "plan-details": (30, 30, 29, 29),
|
||||
"result-details": (30, 30, 19, 19),
|
||||
# The plan's words, plus the recorded choice by name, its row (the one
|
||||
# press back to the whole list) and Change where (back to the setups,
|
||||
# the plan having opened on the one found).
|
||||
"plan-record": (38, 42, 42, 42),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -68,7 +68,8 @@ On Batocera, Recalbox and similar setups, see
|
||||
|
||||
## 2. Check the setup it found
|
||||
|
||||
It opens a menu:
|
||||
With one setup found, it opens on step 3, its folder on the second line.
|
||||
With none or several, it asks:
|
||||
|
||||
```
|
||||
RetroBIOS: Where to install?
|
||||
@@ -80,8 +81,8 @@ It opens a menu:
|
||||
|
||||
- **Right folder?** Press Enter.
|
||||
- **Wrong folder, or a handheld's SD card?** Choose
|
||||
**Another setup or folder**. Press / and type the device (Miyoo Mini),
|
||||
or browse.
|
||||
**Another setup or folder** (**Change where** from step 3).
|
||||
Press / and type the device (Miyoo Mini), or browse.
|
||||
|
||||
Arrows move, Enter chooses, Esc goes back (A and B on a handheld). On a
|
||||
touch screen, tap a row twice to open it, once to tick a box.
|
||||
@@ -89,7 +90,7 @@ touch screen, tap a row twice to open it, once to tick a box.
|
||||
## 3. Install
|
||||
|
||||
Press Enter on **Install**. **Choose systems** picks the consoles to
|
||||
keep.
|
||||
keep. Run it again later: it keeps that choice, one press.
|
||||
|
||||
**Game data** (only a game reads it) has its own row.
|
||||
|
||||
|
||||
+55
-3
@@ -169,8 +169,13 @@ Enter checks the BIOS folder when nothing can be copied.
|
||||
## The full-screen interface { #the-full-screen-interface }
|
||||
|
||||
Run at a terminal, the installer opens a full screen that a keyboard or a
|
||||
controller drives. Three presses of A or Enter install everything the setup
|
||||
reads: the setup found here, the plan, then the result. The plan screen says
|
||||
controller drives. With one setup found here, it opens on that setup's plan,
|
||||
and two presses of A or Enter install everything it reads: the plan, then the
|
||||
result. **Change where** (or B) goes back to the setups, which open first when
|
||||
none or several are found. After an install, the plan of the same folder opens
|
||||
on the same choice (**Same choice as on 10 Nov: yes**, the
|
||||
[install record](#the-install-record)), and **no** takes the whole list, or the
|
||||
games of the card when it holds some. The plan screen says
|
||||
what it installs, its size and where, before anything is written. **Details**
|
||||
(`d`) adds the rest: how many files are in place, the free space, the files
|
||||
left out and why.
|
||||
@@ -371,6 +376,8 @@ file list is downloaded once and handed to each run.
|
||||
that archive in use and downloads its own copy. A file already there with other contents
|
||||
is moved aside first, see [Files already in the folder](#files-you-already-had).
|
||||
8. Copies into standalone emulator directories, only with `--standalone-copies`.
|
||||
9. Writes the [install record](#the-install-record): each file it placed, with
|
||||
its SHA-1, and the choice it made.
|
||||
|
||||
The file list names the version of the platform it was built for (Batocera
|
||||
43.1, RetroBat 8.3.1). Where the system writes its own version in a file, the
|
||||
@@ -444,6 +451,50 @@ ones, grouped by system with the emulators that read them, and point at the
|
||||
emulator pages. They do not change the exit code of `--check`, since no run of
|
||||
the installer can fetch them.
|
||||
|
||||
## The install record { #the-install-record }
|
||||
|
||||
An install that places files writes what it placed in a record beside the BIOS
|
||||
folder: `retrobios-backup/<folder name>.install.json`, in the same holder as
|
||||
the [backups](#files-you-already-had). A folder with nothing writable beside it
|
||||
(a drive's root) keeps it in `retrobios-backup` inside the folder. It is never
|
||||
loose in the BIOS folder, which DuckStation, SwanStation and Rustation read
|
||||
whole. On a card it travels with the card.
|
||||
|
||||
The record is JSON, one entry per setup:
|
||||
|
||||
```json
|
||||
{"schema": 1, "installer": "48e57b2f26e2",
|
||||
"folder": "/home/me/.config/retroarch/system",
|
||||
"setups": {"retroarch": {
|
||||
"written": "2026-11-10T09:48:36Z",
|
||||
"list": {"version": "v1.22.2", "generated": "2026-11-10T01:52:59Z"},
|
||||
"choice": ["--system", "nintendo-gba"],
|
||||
"files": [{"dest": "gba_bios.bin", "root": "", "sha1": "4176d5fc...",
|
||||
"size": 16384, "from": "download"}]}}}
|
||||
```
|
||||
|
||||
`files` holds each file this run and the earlier ones wrote, with the SHA-1
|
||||
checked before it was moved into place: `root` is empty for the BIOS folder,
|
||||
`save` for the save directory and `data` for a data tree's member, and `from`
|
||||
says `download`, `copy` (this machine, `--from`) or `pack`. A file that was
|
||||
already in place is not added: the record names only what the installer wrote.
|
||||
`choice` is the options of the run that decided what to install, `--all-files`
|
||||
for the whole list.
|
||||
|
||||
The next run for the same setup and folder takes that choice when nothing on
|
||||
the command line chose: line mode installs the same systems
|
||||
(`Installing 1 file (16.0 KB) for RetroArch, same choice as on 10 Nov.`), and
|
||||
the full screen opens on it (**Same choice as on 10 Nov: yes**). Any option of
|
||||
the choice (`--system`, `--core`, `--region`, `--for-games`, `--no-game-data`,
|
||||
`--target`, `--every-board`, `--emulator`, `--installed-cores`, `--declared-only`)
|
||||
replaces it, `--all-files` installs the whole list, and a run over several
|
||||
setups or from a file list (`--manifest`) never reads it. `--check` and
|
||||
`--dry-run` read it too, and say so. To forget it, delete the file. A record
|
||||
that cannot be read, or that another version wrote, is said once and ignored,
|
||||
and the next install writes a new one. A record kept for a folder of another
|
||||
name (copied or renamed with it) is not applied. With `--events` the record
|
||||
gives a `record` event, see [Machine-readable events](#machine-readable-events).
|
||||
|
||||
## Reading a run { #reading-a-run }
|
||||
|
||||
<!-- generated: run-date -->
|
||||
@@ -1885,11 +1936,12 @@ other schemas under `api/v1/schemas/`.
|
||||
| `downloaded` | `dest`, `ok`, `reason`, `size`, `files_done`, `files_total`, `bytes_done`, `bytes_total` | Each finished file, downloaded or copied from where a `found` event named it. `reason` says why one failed, and is empty otherwise |
|
||||
| `summary` | `mode`, `installed`, `up_to_date`, `pending`, `failed`, `skipped`, `left_out`, `kept`, `interrupted` | The outcome. `pending` counts what a next run would still fetch, `failed` lists `{dest, reason}`, `skipped` lists `{reason, label, files, required}` for the files the collection cannot supply, `left_out` lists `{category, label, files, dests}` for what `--no-game-data` left out, and is empty without it. `kept` lists `{folder, files, bytes}`, one per backup folder, for the files in place the run moved aside before replacing them (with `--dry-run`, would move), and is empty when none. Not written for a listing |
|
||||
| `app` | `system`, `folder`, `ok`, `reason`, `dests`, `written`, `kept` | After `summary`, with `--add-app` or a yes to its question: the RetroBIOS app on the card of `system` whose root is `folder`. `dests` lists the app's files below that root, `written` the ones this run wrote (empty when each was in place), `kept` the backup folders as in `summary`. `ok` is false, with `reason`, when the card takes no app (before anything is written, code 2), when the install did not finish, or when a file could not be fetched, verified or written (code 1) |
|
||||
| `record` | `platform`, `folder`, `path`, `status`, `choice`, `date`, `files`, `message` | The [install record](#the-install-record) of `folder`, kept at `path`. `status` is `applied` before `choices` when the run took its choice, nothing on the command line having chosen, `written` before `summary` once an install placed files, `unreadable` when it cannot be read (said once, ignored), or `not_written` when it could not be saved, the install itself unharmed. `choice` is the recorded options as words, `date` when it was written (UTC), `files` how many files it holds for the platform |
|
||||
| `end` | `code` | Last line, with the exit status the process returns (see [Exit status](#exit-status)) |
|
||||
|
||||
<!-- /generated: events -->
|
||||
|
||||
Schema 1 can gain a kind (`found` came with `--from`, `declared` with `--declared-only`, `unsearched` with the search that goes on past a closed folder, `app` with `--add-app`, `data` with the size of the data trees, `version` with the list's version), never a key of an
|
||||
Schema 1 can gain a kind (`found` came with `--from`, `declared` with `--declared-only`, `unsearched` with the search that goes on past a closed folder, `app` with `--add-app`, `data` with the size of the data trees, `version` with the list's version, `record` with the install record), never a key of an
|
||||
existing kind: a reader skips a kind it does not know. An option refused
|
||||
before the run starts (`install.py: error:`, code 2) writes
|
||||
no event at all. A program reads a missing `end` as that case, or as a
|
||||
|
||||
Reference in new issue
Block a user