mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-11 12:33:53 -06:00
fix: check the install record before using it
A record's choice holds only the options of the choice and their values, or the whole record is said once and ignored: it no longer reaches the commands printed for the user. A recorded games folder is kept only under the folder that holds the BIOS folder. A recorded option the list can no longer apply is left out in one line, never refused. The record is read only when it is a regular file of at most 16 MiB, its day is unknown when no clock can show it, one a newer installer wrote is left as it is, and a setup that cannot be used is dropped alone. The plan opened on the one setup found keeps that screen's notes under Details.
This commit is contained in:
1 parent
8f2018e7c2
commit
5f3f4cff38
7 files changed
+489
-64
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 = "70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca"
|
||||
$defaultInstallSha256 = "37442a7c0d307b0b95a40d7630cf57025a6498fea6223076892371b2d6363147"
|
||||
# The pin above refuses any changed byte: this cap only stops a body that
|
||||
# cannot be the installer (a sign-in portal's page) before it is hashed.
|
||||
$maximumInstallerBytes = 8MB
|
||||
|
||||
+292
-49
@@ -6136,63 +6136,165 @@ def record_paths(folder: Path) -> list[Path]:
|
||||
return [holder / name for holder, _prefix in KeptFiles._holders(folder)]
|
||||
|
||||
|
||||
def _valid_setup(setup: object) -> bool:
|
||||
# Read whole, never past this: a record of every file of the longest list
|
||||
# holds 1.6 MB per setup, and a few setups share one folder.
|
||||
MAX_RECORD_BYTES = 16 * 1024 * 1024
|
||||
# The choice options that take a value. --for-games takes one or none.
|
||||
_RECORD_VALUED = ("--system", "--core", "--emulator", "--region", "--target")
|
||||
|
||||
|
||||
def games_folder_kept(value: str, folder: Path) -> bool:
|
||||
"""Whether a recorded --for-games folder may be read again: an absolute
|
||||
path under the folder that holds the BIOS folder (a card's root, the
|
||||
setup's own folder), never another drive or a network share."""
|
||||
if not value or value.startswith(("\\\\", "//")) or not os.path.isabs(value):
|
||||
return False
|
||||
parent = os.path.normcase(os.path.normpath(os.path.dirname(os.path.abspath(folder))))
|
||||
games = os.path.normcase(os.path.normpath(value))
|
||||
try:
|
||||
return os.path.commonpath([parent, games]) == parent
|
||||
except ValueError:
|
||||
# Two drives on Windows: no common path.
|
||||
return False
|
||||
|
||||
|
||||
def valid_choice(choice: list[str], folder: Path) -> bool:
|
||||
"""Whether every word of a recorded choice is an option of the choice or
|
||||
the value right after one: a record naming --dest, --from or --manifest
|
||||
would put them in the commands printed for the user to copy."""
|
||||
words = list(choice)
|
||||
while words:
|
||||
flag = words.pop(0)
|
||||
if flag not in RECORD_CHOICE:
|
||||
return False
|
||||
if flag in _RECORD_VALUED:
|
||||
if not words or words[0].startswith("-") or not words[0].strip():
|
||||
return False
|
||||
value = words.pop(0)
|
||||
if any(ord(char) < 32 or ord(char) == 127 for char in value):
|
||||
return False
|
||||
elif flag == "--for-games" and words and not words[0].startswith("--"):
|
||||
if not games_folder_kept(words.pop(0), folder):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _valid_setup(setup: object, folder: Path) -> bool:
|
||||
if not isinstance(setup, dict):
|
||||
return False
|
||||
choice, files = setup.get("choice"), setup.get("files")
|
||||
return (isinstance(setup.get("written"), str) and isinstance(choice, list)
|
||||
and all(isinstance(word, str) for word in choice) and isinstance(files, list)
|
||||
and all(isinstance(word, str) for word in choice) and valid_choice(choice, folder)
|
||||
and isinstance(files, list)
|
||||
and all(isinstance(item, dict) and isinstance(item.get("dest"), str)
|
||||
and isinstance(item.get("sha1"), str) and isinstance(item.get("size"), int)
|
||||
for item in files))
|
||||
|
||||
|
||||
def read_record(folder: Path) -> tuple[dict | None, Path | None, str]:
|
||||
"""(the record of folder, where it is, why it cannot be used). Neither a
|
||||
record nor a reason where there is none."""
|
||||
class RecordRead(NamedTuple):
|
||||
"""What reading a record found: the record with its usable setups, where
|
||||
it is, why it cannot be used, whether a newer installer wrote it, and
|
||||
the setups it holds that cannot be used."""
|
||||
|
||||
data: dict | None
|
||||
path: Path | None
|
||||
problem: str
|
||||
newer: bool = False
|
||||
dropped: tuple = ()
|
||||
|
||||
|
||||
def _read_record_bytes(path: Path) -> bytes:
|
||||
"""The bytes of a record that is a regular file of a record's size. A
|
||||
FIFO, a device or a folder is refused before it is read: reading one
|
||||
waited forever."""
|
||||
info = os.stat(path)
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise OSError(errno.EINVAL, "it is not a file")
|
||||
if info.st_size > MAX_RECORD_BYTES:
|
||||
raise OSError(errno.EFBIG, "it is larger than any record")
|
||||
flags = os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_BINARY", 0)
|
||||
handle = os.open(path, flags)
|
||||
try:
|
||||
# Opened without waiting, then checked again: what is open now is
|
||||
# what is read, whatever took the name since the stat.
|
||||
if not stat.S_ISREG(os.fstat(handle).st_mode):
|
||||
raise OSError(errno.EINVAL, "it is not a file")
|
||||
chunks, left = [], MAX_RECORD_BYTES + 1
|
||||
while left > 0:
|
||||
chunk = os.read(handle, min(left, 1 << 20))
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
left -= len(chunk)
|
||||
finally:
|
||||
os.close(handle)
|
||||
data = b"".join(chunks)
|
||||
if len(data) > MAX_RECORD_BYTES:
|
||||
raise OSError(errno.EFBIG, "it is larger than any record")
|
||||
return data
|
||||
|
||||
|
||||
def load_record(folder: Path) -> RecordRead:
|
||||
"""The record of folder, read once (RecordRead)."""
|
||||
for path in record_paths(folder):
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
text = _read_record_bytes(path).decode("utf-8")
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
continue
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
reason = getattr(exc, "strerror", None) or str(exc)
|
||||
return None, path, f"The install record {path} cannot be read ({reason})"
|
||||
return RecordRead(None, path, f"The install record {path} cannot be read ({reason})")
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except ValueError:
|
||||
return None, path, f"The install record {path} is damaged"
|
||||
if not isinstance(data, dict) or not isinstance(data.get("setups"), dict):
|
||||
return None, path, f"The install record {path} is damaged"
|
||||
if data.get("schema") != RECORD_SCHEMA:
|
||||
return None, path, (f"The install record {path} was written by another "
|
||||
"version of the installer")
|
||||
if not all(_valid_setup(setup) for setup in data["setups"].values()):
|
||||
return None, path, f"The install record {path} is damaged"
|
||||
return data, path, ""
|
||||
return None, None, ""
|
||||
return RecordRead(None, path, f"The install record {path} is damaged")
|
||||
schema = data.get("schema") if isinstance(data, dict) else None
|
||||
if isinstance(schema, int) and not isinstance(schema, bool) and schema > RECORD_SCHEMA:
|
||||
# Its shape is the newer installer's: kept for it, never read.
|
||||
return RecordRead(None, path, f"The install record {path} was written by a newer "
|
||||
"installer", newer=True)
|
||||
if (not isinstance(data, dict) or not isinstance(data.get("setups"), dict)
|
||||
or not isinstance(data.get("folder"), str) or schema != RECORD_SCHEMA):
|
||||
return RecordRead(None, path, f"The install record {path} is damaged")
|
||||
setups = {name: setup for name, setup in data["setups"].items()
|
||||
if _valid_setup(setup, folder)}
|
||||
dropped = tuple(sorted(str(name) for name in data["setups"] if name not in setups))
|
||||
return RecordRead({**data, "setups": setups}, path, "", dropped=dropped)
|
||||
return RecordRead(None, None, "")
|
||||
|
||||
|
||||
def read_record(folder: Path) -> tuple[dict | None, Path | None, str]:
|
||||
"""(the record of folder, where it is, why it cannot be used). Neither a
|
||||
record nor a reason where there is none."""
|
||||
found = load_record(folder)
|
||||
return found.data, found.path, found.problem
|
||||
|
||||
|
||||
def setup_record(plat: str, folder: Path) -> tuple[dict | None, Path | None, str]:
|
||||
"""The record of plat in folder, where it is, and why it cannot be used.
|
||||
A record written for a folder of another name (copied, or renamed with
|
||||
it) is not this folder's."""
|
||||
data, path, problem = read_record(folder)
|
||||
if data is None:
|
||||
return None, path, problem
|
||||
written_for = str(data.get("folder") or "")
|
||||
it) is not this folder's. A setup of it that cannot be used (an option
|
||||
the choice has no place for, a games folder elsewhere) is damaged, the
|
||||
others still read."""
|
||||
found = load_record(folder)
|
||||
if found.data is None:
|
||||
return None, found.path, found.problem
|
||||
written_for = str(found.data.get("folder") or "")
|
||||
if Path(written_for).name != Path(os.path.abspath(folder)).name:
|
||||
return None, path, ""
|
||||
return data["setups"].get(plat), path, ""
|
||||
return None, found.path, ""
|
||||
if plat in found.dropped:
|
||||
return None, found.path, f"The install record {found.path} is damaged"
|
||||
return found.data["setups"].get(plat), found.path, ""
|
||||
|
||||
|
||||
def record_day(setup: dict) -> str:
|
||||
"""The day a record was written, as the plan says it: 3 Nov."""
|
||||
try:
|
||||
when = datetime.datetime.fromisoformat(setup["written"].replace("Z", "+00:00"))
|
||||
except (KeyError, ValueError):
|
||||
# Year 1 or 9999 cannot be moved to local time: an unknown day.
|
||||
local = when.astimezone()
|
||||
except (KeyError, TypeError, AttributeError, ValueError, OverflowError, OSError):
|
||||
return ""
|
||||
local = when.astimezone()
|
||||
day = f"{local.day} {_MONTHS[local.month - 1]}"
|
||||
return day if local.year == datetime.datetime.now().year else f"{day} {local.year}"
|
||||
|
||||
@@ -6265,9 +6367,18 @@ def write_record(
|
||||
earlier runs placed: (where, the record of plat, why it was not
|
||||
written). A record that cannot be read is replaced."""
|
||||
folder = Path(os.path.abspath(folder))
|
||||
data, found, _problem = read_record(folder)
|
||||
if data is None:
|
||||
data = {"setups": {}}
|
||||
read = load_record(folder)
|
||||
if read.newer:
|
||||
# What a newer installer wrote is kept for it: rewritten here, the
|
||||
# other setups' history would go.
|
||||
if str(read.path) not in _RECORD_SAID:
|
||||
_RECORD_SAID.add(str(read.path))
|
||||
print(f" {read.problem}: left as it is.")
|
||||
return None, {"files": [], "written": ""}, f"{read.problem}: left as it is"
|
||||
found = read.path
|
||||
# A record that cannot be read is replaced, said when it was read. One
|
||||
# with a setup that cannot be used keeps its others.
|
||||
data = read.data if read.data is not None else {"setups": {}}
|
||||
previous = data["setups"].get(plat) or {}
|
||||
merged = {(str(item.get("root") or ""), item["dest"]): item
|
||||
for item in previous.get("files") or []}
|
||||
@@ -6339,6 +6450,29 @@ def say_record_problem(plat: str, folder: Path, path: Path | None, problem: str)
|
||||
choice=[], date="", files=0, message=message)
|
||||
|
||||
|
||||
def by_record(args: argparse.Namespace) -> bool:
|
||||
"""Whether the run's choice came from the install record: nothing of
|
||||
it was typed, so a part that cannot apply is left out, never refused."""
|
||||
return bool(getattr(args, "from_record", False))
|
||||
|
||||
|
||||
def drop_recorded(args: argparse.Namespace, plat: str, flag: str, why: str,
|
||||
keep: list[str] | None = None) -> None:
|
||||
"""Leave out a recorded option that cannot apply, in one line: the run
|
||||
goes on, and the record written after it no longer holds it. keep, for
|
||||
an option of several names, is the names that still apply."""
|
||||
name = flag[2:].replace("-", "_")
|
||||
chosen = _INVOCATION.chosen.get(plat, [])
|
||||
if keep:
|
||||
setattr(args, name, list(keep))
|
||||
_INVOCATION.chosen[plat] = [option for option in chosen if option[0] != flag] + [
|
||||
(flag, value) for value in keep]
|
||||
else:
|
||||
setattr(args, name, build_parser().get_default(name))
|
||||
_INVOCATION.chosen[plat] = [option for option in chosen if option[0] != flag]
|
||||
print(f" Left out of the recorded choice: {flag} ({why}).")
|
||||
|
||||
|
||||
def apply_record(args: argparse.Namespace, plat: str, folder: Path) -> dict | None:
|
||||
"""Take the choice recorded for plat in folder as the run's, as if its
|
||||
options had been typed: the plan's first choice. Returns the record,
|
||||
@@ -6365,6 +6499,12 @@ def apply_record(args: argparse.Namespace, plat: str, folder: Path) -> dict | No
|
||||
# a person expects, and said nothing new.
|
||||
_RECORD_TAKEN[plat] = setup
|
||||
_INVOCATION.chosen[plat] = [option for option in options if option[0] != "--all-files"]
|
||||
if isinstance(args.for_games, Path) and not _is_dir(args.for_games):
|
||||
# The games folder recorded is gone: the card's own games are read.
|
||||
_INVOCATION.chosen[plat] = [(flag, None if flag == "--for-games" else value)
|
||||
for flag, value in _INVOCATION.chosen[plat]]
|
||||
args.for_games = True
|
||||
print(" Left out of the recorded choice: its games folder (no longer there).")
|
||||
message = record_menu_words(setup)
|
||||
EVENTS.emit("record", platform=plat, folder=str(folder), path=str(path), status="applied",
|
||||
choice=list(setup["choice"]), date=setup["written"],
|
||||
@@ -10502,12 +10642,47 @@ def _known_names(values: list[str] | None, known: list[str], label: str,
|
||||
try:
|
||||
resolve_choices([value], known, label, aliases)
|
||||
except ValueError:
|
||||
detail(f" {value} is no longer on the list: left out of the recorded choice")
|
||||
continue
|
||||
kept.append(value)
|
||||
return kept
|
||||
|
||||
|
||||
def _recorded_names(args: argparse.Namespace, plat: str, flag: str, known: list[str],
|
||||
aliases: dict[str, str] | None = None) -> None:
|
||||
"""A recorded --system, --core or --emulator kept to the names the list
|
||||
still holds, the others left out in one line."""
|
||||
name = flag[2:].replace("-", "_")
|
||||
values = list(getattr(args, name) or [])
|
||||
kept = _known_names(values, known, flag[2:], aliases)
|
||||
if kept != values:
|
||||
gone = ", ".join(value for value in values if value not in kept)
|
||||
drop_recorded(args, plat, flag, f"{gone} no longer on the list", kept)
|
||||
|
||||
|
||||
def _recorded_emulators(args: argparse.Namespace, plat: str, manifest: dict,
|
||||
files: list[dict], omitted: list[dict]) -> None:
|
||||
"""A recorded --emulator that cannot narrow this list, left out."""
|
||||
if "system_cores" not in manifest:
|
||||
drop_recorded(args, plat, "--emulator", "the list does not say which emulators "
|
||||
"read which systems")
|
||||
return
|
||||
_recorded_names(args, plat, "--emulator", sorted(emulator_names(manifest, files, omitted)))
|
||||
|
||||
|
||||
def _recorded_regions(args: argparse.Namespace, plat: str) -> None:
|
||||
"""A recorded --region kept to the regions this installer knows."""
|
||||
values = list(args.region or [])
|
||||
kept = []
|
||||
for value in values:
|
||||
try:
|
||||
resolve_regions([value])
|
||||
except ValueError:
|
||||
continue
|
||||
kept.append(value)
|
||||
if kept != values:
|
||||
drop_recorded(args, plat, "--region", "a region this installer does not know", kept)
|
||||
|
||||
|
||||
def _requested_narrowing(
|
||||
args: argparse.Namespace, plat: str, files: list[dict], omitted: list[dict],
|
||||
manifest: dict | None = None,
|
||||
@@ -10524,6 +10699,22 @@ def _requested_narrowing(
|
||||
asked = [(flag, axis) for flag, given, axis in (
|
||||
("--system", args.system, "systems"), ("--core", args.core, "cores"),
|
||||
("--region", args.region, "regions")) if given]
|
||||
if getattr(args, "from_record", False):
|
||||
# A recorded option that cannot narrow this list is left out: nobody
|
||||
# typed it, and every run without options would stop on it.
|
||||
for flag, axis in asked:
|
||||
if axis not in recorded:
|
||||
drop_recorded(args, plat, flag, f"the list records no {axis}")
|
||||
asked = [(flag, axis) for flag, axis in asked if axis in recorded]
|
||||
_recorded_regions(args, plat)
|
||||
choices = available_choices(files, omitted)
|
||||
if args.system:
|
||||
_recorded_names(args, plat, "--system", choices["systems"],
|
||||
system_aliases(manifest))
|
||||
if args.core:
|
||||
_recorded_names(args, plat, "--core", choices["cores"])
|
||||
if not (args.system or args.core or args.region):
|
||||
return None
|
||||
for flag, axis in asked:
|
||||
if axis in recorded:
|
||||
continue
|
||||
@@ -10536,14 +10727,6 @@ def _requested_narrowing(
|
||||
print(_narrowing_advice(plat, axis, flag, recorded, pack_alone), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
choices = available_choices(files, omitted)
|
||||
if getattr(args, "from_record", False):
|
||||
# A name the list no longer holds is dropped from a recorded choice,
|
||||
# never refused: nobody typed it.
|
||||
args.system = _known_names(args.system, choices["systems"], "system",
|
||||
system_aliases(manifest)) or None
|
||||
args.core = _known_names(args.core, choices["cores"], "core") or None
|
||||
if not (args.system or args.core or args.region):
|
||||
return None
|
||||
try:
|
||||
systems = resolve_choices(
|
||||
args.system or [], choices["systems"], "system", system_aliases(manifest),
|
||||
@@ -14577,6 +14760,8 @@ def main() -> None:
|
||||
# the menu. Yes to every question is no question.
|
||||
args.no_input = True
|
||||
_INVOCATION.record(args)
|
||||
if os.environ.get(TUI_RECORD_ENV):
|
||||
args.from_record = True
|
||||
if os.environ.get(TUI_LIST_ENV):
|
||||
# The full screen's own copy of the list, deleted when it closes: a
|
||||
# command naming it fails once the screen is gone.
|
||||
@@ -16424,7 +16609,12 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
if not written and setup_record(plat, folder)[0] is None:
|
||||
# Nothing of ours is there to record, and no choice to keep.
|
||||
return
|
||||
options = _INVOCATION.flags(plat)
|
||||
# A games folder is kept only where the next run reads it from a
|
||||
# record (games_folder_kept): elsewhere the card's own games are.
|
||||
options = [(flag, value if flag != "--for-games" or not value
|
||||
else os.path.abspath(value)
|
||||
if games_folder_kept(os.path.abspath(value), folder) else None)
|
||||
for flag, value in _INVOCATION.flags(plat)]
|
||||
if games:
|
||||
options += [("--for-games", None),
|
||||
*([("--required-only", None)] if args.required_only else [])]
|
||||
@@ -16441,16 +16631,23 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
f"Recorded {_plural(count, 'file')} and this choice in {path}.")
|
||||
|
||||
if len(platforms) == 1 and not listing and not choice_given(args):
|
||||
if apply_record(args, *platforms[0]) is not None and args.target:
|
||||
known = fetch_targets(platforms[0][0])
|
||||
if known is not None and args.target not in known:
|
||||
# A board the list no longer names: every board's files.
|
||||
args.target = None
|
||||
apply_record(args, *platforms[0])
|
||||
|
||||
# --target is checked for every platform before anything is written, as
|
||||
# --installed-cores is below: EmuDeck installed 3.2 GB and only then was
|
||||
# steamos refused for RetroArch, with no summary of what had landed.
|
||||
target_lists: dict[str, dict] = {}
|
||||
if args.target and by_record(args) and len(platforms) == 1:
|
||||
# The screen's runs carry the record's options as typed ones.
|
||||
plat = platforms[0][0]
|
||||
if LOCAL.offline and local_targets(plat) is None and plat in TARGET_PLATFORMS:
|
||||
drop_recorded(args, plat, "--target", "the board list needs the internet")
|
||||
else:
|
||||
with contextlib.redirect_stderr(io.StringIO()):
|
||||
known = fetch_targets(plat)
|
||||
if known is None or args.target not in known:
|
||||
drop_recorded(args, plat, "--target", "the board list does not name it"
|
||||
if known is not None else "the board list could not be read")
|
||||
if args.target:
|
||||
for plat_name, _bios_path in platforms:
|
||||
targets = fetch_targets(plat_name)
|
||||
@@ -16485,6 +16682,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
searched = [args.cores_dir] if args.cores_dir else core_dirs(plat_name, bios_path)
|
||||
cores_found[(plat_name, bios_path)] = installed_cores(searched)
|
||||
cores_searched[(plat_name, bios_path)] = searched
|
||||
elif args.installed_cores and by_record(args):
|
||||
drop_recorded(args, plat_name, "--installed-cores",
|
||||
f"{plat_name} ships its cores with the system")
|
||||
elif args.installed_cores:
|
||||
print(
|
||||
f"Error: {plat_name} ships its cores with the system. "
|
||||
@@ -16493,7 +16693,10 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
)
|
||||
sys.exit(1)
|
||||
if args.installed_cores and not cores_found[(plat_name, bios_path)]:
|
||||
_no_core_found(plat_name, bios_path, searched)
|
||||
if by_record(args):
|
||||
drop_recorded(args, plat_name, "--installed-cores", "no core is installed")
|
||||
else:
|
||||
_no_core_found(plat_name, bios_path, searched)
|
||||
|
||||
for plat_name, bios_path in platforms:
|
||||
if bios_path not in installed_paths:
|
||||
@@ -16536,6 +16739,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
)
|
||||
|
||||
game_data_cut = 0
|
||||
if args.no_game_data and not manifest.get("categorized") and by_record(args):
|
||||
drop_recorded(args, plat_name, "--no-game-data", "the list marks no game data")
|
||||
if args.no_game_data and not manifest.get("categorized"):
|
||||
# Without the field nothing is marked, and the run would
|
||||
# install the game data it was asked to leave out.
|
||||
@@ -16619,6 +16824,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
found_cores = []
|
||||
elif found_cores:
|
||||
detail(f" {_plural(len(found_cores), 'core')} installed")
|
||||
if args.installed_cores and "system_cores" not in manifest and by_record(args):
|
||||
drop_recorded(args, plat_name, "--installed-cores",
|
||||
"the list does not say which cores read which systems")
|
||||
if args.installed_cores:
|
||||
if "system_cores" not in manifest:
|
||||
print(
|
||||
@@ -16644,6 +16852,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
else (files, omitted_files)))
|
||||
continue
|
||||
|
||||
if args.emulator and by_record(args):
|
||||
_recorded_emulators(args, plat_name, manifest, files, omitted_files)
|
||||
if args.emulator:
|
||||
files, omitted_files = _narrow_to_emulators(
|
||||
args.emulator, manifest, plat_name, files, omitted_files
|
||||
@@ -16756,7 +16966,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
return _INVOCATION.command(plat, folder)
|
||||
|
||||
# The record's options, which the menu's commands overwrite.
|
||||
recorded = _INVOCATION.chosen.get(plat_name)
|
||||
record_options_kept = _INVOCATION.chosen.get(plat_name)
|
||||
selection = _prompt_custom_selection(
|
||||
plat_name, bare, bare_omitted + unplaced, installed,
|
||||
detected_os(plat_name, bios_path) or display,
|
||||
@@ -16808,9 +17018,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
_INVOCATION.chosen[plat_name] = []
|
||||
else:
|
||||
_INVOCATION.chosen.pop(plat_name, None)
|
||||
if kept_record and recorded is not None:
|
||||
if kept_record and record_options_kept is not None:
|
||||
# Enter on the recorded choice: still the record's options.
|
||||
_INVOCATION.chosen[plat_name] = recorded
|
||||
_INVOCATION.chosen[plat_name] = record_options_kept
|
||||
elif selection is None and plat_name in _RECORD_TAKEN and not games_applied:
|
||||
# e: every file, the recorded narrowing given up.
|
||||
args.system = args.core = args.region = None
|
||||
@@ -17465,6 +17675,10 @@ TUI_ESC_WAIT = 0.04
|
||||
TUI_FRAME = 0.1
|
||||
# Set for a child run handed the interface's own copy of the file list.
|
||||
TUI_LIST_ENV = "RETROBIOS_TUI_LIST"
|
||||
# Set for a child run whose choice options are the install record's, kept
|
||||
# on the screen: a part of them that cannot apply is left out, as line mode
|
||||
# leaves it out of a record it reads, never refused.
|
||||
TUI_RECORD_ENV = "RETROBIOS_TUI_RECORDED"
|
||||
_PASTE_START = "\x1b[200~"
|
||||
_PASTE_END = "\x1b[201~"
|
||||
# Turned on by the interface, so a pasted path arrives as one piece and its
|
||||
@@ -18900,6 +19114,19 @@ def tui_argv(state: TuiState, run: str, python: str, script: str) -> list[str]:
|
||||
return argv + _TUI_MODES[run]
|
||||
|
||||
|
||||
def tui_run_env(state: TuiState, own_list: bool) -> dict | None:
|
||||
"""The environment of a child run: None for this one's own, else with
|
||||
the markers the run reads (TUI_LIST_ENV, TUI_RECORD_ENV)."""
|
||||
marks = {TUI_LIST_ENV: "1"} if own_list else {}
|
||||
if tui_record_kept(state):
|
||||
marks[TUI_RECORD_ENV] = "1"
|
||||
if not marks and TUI_RECORD_ENV not in os.environ:
|
||||
return None
|
||||
env = {key: value for key, value in os.environ.items() if key != TUI_RECORD_ENV}
|
||||
env.update(marks)
|
||||
return env
|
||||
|
||||
|
||||
def tui_hint(state: TuiState) -> Invocation:
|
||||
"""The command the screen's choices make, every option of the run in
|
||||
it: what "Same choice without questions" and the sudo advice give."""
|
||||
@@ -22675,6 +22902,8 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list:
|
||||
if check is state.check:
|
||||
lines += _unplaced_lines(_choice_summary(state), state,
|
||||
"Choose it in Options, Save folder (--save-dir).")
|
||||
if check is state.check:
|
||||
lines += _found_lines(state)
|
||||
if check is state.check and state.from_folders:
|
||||
# A folder copied from that holds none of them: the plan came back
|
||||
# the same, still saying to copy from a folder.
|
||||
@@ -23722,6 +23951,20 @@ def _unavailable(state: TuiState) -> tuple[list[dict], int]:
|
||||
return needed, others
|
||||
|
||||
|
||||
def _found_lines(state: TuiState) -> list[TuiLine]:
|
||||
"""What the setups screen said of this machine, on a plan that opened
|
||||
in its place: the setup found by its label, and the notes (ES-DE found,
|
||||
what detection printed) on Details, one line on the plan saying so."""
|
||||
if not state.opened_on_plan or not state.setups:
|
||||
return []
|
||||
lines = _details([f"Found here: {state.setups[0][2]}.", *state.notes])
|
||||
if state.notes:
|
||||
brief = _styled(f"{_plural(len(state.notes), 'note')} on this machine: Details.")
|
||||
brief.brief = True
|
||||
lines.insert(0, brief)
|
||||
return lines
|
||||
|
||||
|
||||
def _unavailable_brief(state: TuiState) -> list[TuiLine]:
|
||||
"""The count of the files an emulator needs that cannot be supplied, on
|
||||
the screen itself: their names are on Details, and in place with
|
||||
@@ -25303,7 +25546,7 @@ def run_tui(args: argparse.Namespace) -> int:
|
||||
try:
|
||||
current.append(_EngineRun(
|
||||
tui_argv(state, effect[1], sys.executable, script), log, inbox,
|
||||
state.run_id, {**os.environ, TUI_LIST_ENV: "1"} if own_list else None))
|
||||
state.run_id, tui_run_env(state, own_list)))
|
||||
except OSError as exc:
|
||||
inbox.put(("ended", state.run_id, 1,
|
||||
[f"The installer could not be started: {exc}"]))
|
||||
|
||||
+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="70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca"
|
||||
DEFAULT_INSTALL_SHA256="37442a7c0d307b0b95a40d7630cf57025a6498fea6223076892371b2d6363147"
|
||||
# The pin above refuses any changed byte: this cap only stops a body that
|
||||
# cannot be the installer (a sign-in portal's page) before it is hashed.
|
||||
MAX_INSTALLER_BYTES=8388608
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
{
|
||||
"dest": "roms/ports/retrobios/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 1189759,
|
||||
"sha256": "70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca",
|
||||
"size": 1201450,
|
||||
"sha256": "37442a7c0d307b0b95a40d7630cf57025a6498fea6223076892371b2d6363147",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
@@ -71,8 +71,8 @@
|
||||
"muos": {
|
||||
"archive": {
|
||||
"dest": "ARCHIVE/RetroBIOS.muxapp",
|
||||
"size": 1243384,
|
||||
"sha256": "ae0d36d5f2dda70c39bfe8be474108b92f0a5a80457bc8c07d36736b09b0310d"
|
||||
"size": 1255075,
|
||||
"sha256": "23393eb6b88f7a7875bd034e79c02d81e80409c9403290bb96a75166bf7a60b6"
|
||||
},
|
||||
"members": [
|
||||
{
|
||||
@@ -92,8 +92,8 @@
|
||||
{
|
||||
"name": "RetroBIOS/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 1189759,
|
||||
"sha256": "70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca",
|
||||
"size": 1201450,
|
||||
"sha256": "37442a7c0d307b0b95a40d7630cf57025a6498fea6223076892371b2d6363147",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
@@ -166,8 +166,8 @@
|
||||
{
|
||||
"dest": ".config/modules/retrobios/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 1189759,
|
||||
"sha256": "70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca",
|
||||
"size": 1201450,
|
||||
"sha256": "37442a7c0d307b0b95a40d7630cf57025a6498fea6223076892371b2d6363147",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
|
||||
@@ -25,6 +25,7 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
@@ -619,5 +620,144 @@ class InstallRecord(unittest.TestCase):
|
||||
events(screen, "check")[0]["bytes_needed"])
|
||||
|
||||
|
||||
def record_text(folder: Path, choice: list[str], **setups: object) -> str:
|
||||
"""A record of folder whose retroarch setup chose choice."""
|
||||
setup = {"written": "2026-11-03T12:00:00Z", "list": {}, "choice": choice,
|
||||
"files": [{"dest": "gba_bios.bin", "root": "", "sha1": "0" * 40, "size": 1,
|
||||
"from": "download"}]}
|
||||
return json.dumps({"schema": install.RECORD_SCHEMA, "installer": "x", "folder": str(folder),
|
||||
"setups": {"retroarch": setup, **setups}})
|
||||
|
||||
|
||||
class RecordReview(unittest.TestCase):
|
||||
"""A record is data another program or a person may have written: what
|
||||
it holds is checked before any of it is used or printed."""
|
||||
|
||||
setUp = InstallRecord.setUp
|
||||
run_line = InstallRecord.run_line
|
||||
dests = InstallRecord.dests
|
||||
everything = InstallRecord.everything
|
||||
|
||||
def write(self, choice: list[str], **setups: object) -> None:
|
||||
self.dest.mkdir(parents=True, exist_ok=True)
|
||||
self.record.parent.mkdir(exist_ok=True)
|
||||
self.record.write_text(record_text(self.dest, choice, **setups), encoding="utf-8")
|
||||
|
||||
def test_a_word_outside_the_choice_rejects_the_whole_record(self):
|
||||
for choice in (["--system", "nintendo-gba", "--from", "/mnt/x"],
|
||||
["--manifest", "http://example.invalid/list.json"],
|
||||
["--system", "nintendo-gba", "--dest", "/etc"],
|
||||
["--system", "--yes"], ["--system", "a\nb"], ["nintendo-gba"]):
|
||||
with self.subTest(choice=choice):
|
||||
self.write(choice)
|
||||
proc = self.run_line("--check", "--events")
|
||||
said = events(proc, "record")
|
||||
self.assertEqual([event["status"] for event in said], ["unreadable"], said)
|
||||
self.assertEqual(self.dests(proc), self.everything())
|
||||
text = proc.stdout + proc.stderr
|
||||
for word in ("/mnt/x", "example.invalid", "/etc"):
|
||||
self.assertNotIn(word, text)
|
||||
# The screen reads it through the same function.
|
||||
state = install.TuiState()
|
||||
state.platform, state.dest = "retroarch", self.dest
|
||||
install._tui_record_load(state)
|
||||
self.assertIsNone(state.record)
|
||||
self.assertEqual(install.tui_flags(state), [])
|
||||
|
||||
def test_a_recorded_games_folder_stays_beside_the_bios_folder(self):
|
||||
parent = self.dest.parent
|
||||
self.assertTrue(install.games_folder_kept(str(parent / "roms"), self.dest))
|
||||
self.assertTrue(install.games_folder_kept(str(parent), self.dest))
|
||||
for value in ("/etc", str(parent.parent / "other"), "roms", "\\\\server\\share",
|
||||
"//server/share", ""):
|
||||
with self.subTest(value=value):
|
||||
self.assertFalse(install.games_folder_kept(value, self.dest))
|
||||
self.write(["--for-games", "/etc"])
|
||||
proc = self.run_line("--check", "--events")
|
||||
self.assertEqual(events(proc, "record")[0]["status"], "unreadable")
|
||||
self.assertEqual(self.dests(proc), self.everything())
|
||||
|
||||
def test_a_recorded_option_that_cannot_apply_is_left_out(self):
|
||||
"""A run nobody typed options for went on failing: the list has no
|
||||
cores, no categories and no board list, and a region is unknown."""
|
||||
for choice, typed in ((["--system", "nintendo-gba", "--region", "atlantis"],
|
||||
["--region", "atlantis"]),
|
||||
(["--system", "nintendo-gba", "--emulator", "nosuch"],
|
||||
["--emulator", "nosuch"]),
|
||||
(["--system", "nintendo-gba", "--installed-cores"],
|
||||
["--installed-cores"]),
|
||||
(["--system", "nintendo-gba", "--target", "nosuch"],
|
||||
["--target", "nosuch"]),
|
||||
(["--system", "nintendo-gba", "--no-game-data"],
|
||||
["--no-game-data"])):
|
||||
with self.subTest(choice=choice):
|
||||
self.write(choice)
|
||||
proc = self.run_line("--yes", "--events")
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertEqual(proc.stderr.count("Left out of the recorded choice"), 1,
|
||||
proc.stderr)
|
||||
self.assertEqual(self.dests(proc), ["gba_bios.bin"])
|
||||
# Written again without it: the next run says nothing.
|
||||
kept = json.loads(self.record.read_text())["setups"]["retroarch"]["choice"]
|
||||
self.assertEqual(kept, ["--system", "nintendo-gba"])
|
||||
# Typed, it is refused as before.
|
||||
self.assertNotEqual(self.run_line(*typed, "--check").returncode, 0)
|
||||
|
||||
def test_a_date_no_clock_can_show_is_an_unknown_day(self):
|
||||
if not hasattr(time, "tzset"):
|
||||
self.skipTest("no tzset")
|
||||
zone = os.environ.get("TZ")
|
||||
|
||||
def back() -> None:
|
||||
if zone is None:
|
||||
os.environ.pop("TZ", None)
|
||||
else:
|
||||
os.environ["TZ"] = zone
|
||||
time.tzset()
|
||||
|
||||
self.addCleanup(back)
|
||||
for tz, written in (("UTC+5", "0001-01-01T00:00:00Z"),
|
||||
("UTC-5", "9999-12-31T23:59:59Z")):
|
||||
with self.subTest(written=written):
|
||||
os.environ["TZ"] = tz
|
||||
time.tzset()
|
||||
self.assertEqual(install.record_day({"written": written}), "")
|
||||
|
||||
def test_a_record_that_is_no_file_is_never_read(self):
|
||||
self.dest.mkdir(parents=True)
|
||||
self.record.parent.mkdir()
|
||||
os.mkfifo(self.record)
|
||||
proc = self.run_line("--check", "--events")
|
||||
self.assertIn("it is not a file", events(proc, "record")[0]["message"])
|
||||
self.assertEqual(self.dests(proc), self.everything())
|
||||
self.record.unlink()
|
||||
with open(self.record, "wb") as handle:
|
||||
handle.truncate(install.MAX_RECORD_BYTES + 1)
|
||||
self.assertIn("larger than any record", install.load_record(self.dest).problem)
|
||||
|
||||
def test_a_newer_record_is_left_as_it_is(self):
|
||||
self.dest.mkdir(parents=True)
|
||||
self.record.parent.mkdir()
|
||||
newer = json.dumps({"schema": install.RECORD_SCHEMA + 1, "setups": {"x": {}}})
|
||||
self.record.write_text(newer, encoding="utf-8")
|
||||
proc = self.run_line("--yes", "--events")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
self.assertEqual([event["status"] for event in events(proc, "record")],
|
||||
["unreadable", "not_written"])
|
||||
self.assertEqual(self.record.read_text(encoding="utf-8"), newer)
|
||||
self.assertEqual(proc.stderr.count("written by a newer installer"), 1)
|
||||
|
||||
def test_a_bad_setup_is_dropped_and_the_others_kept(self):
|
||||
good = {"written": "2026-11-03T12:00:00Z", "list": {}, "choice": ["--all-files"],
|
||||
"files": []}
|
||||
self.write(["--system", "nintendo-gba"], lakka=good, batocera={"choice": 3})
|
||||
path, _setup, problem = install.write_record(self.dest, "retropie", {}, ["--all-files"],
|
||||
[])
|
||||
self.assertEqual((path, problem), (self.record, ""))
|
||||
kept = json.loads(self.record.read_text())["setups"]
|
||||
self.assertEqual(sorted(kept), ["lakka", "retroarch", "retropie"])
|
||||
self.assertEqual(kept["retroarch"]["choice"], ["--system", "nintendo-gba"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1148,6 +1148,36 @@ class RecordedPlan(unittest.TestCase):
|
||||
press(state, "enter")
|
||||
self.assertEqual(state.screen, "setup")
|
||||
|
||||
def test_the_notes_of_the_setups_screen_are_on_the_plan(self):
|
||||
"""Opened on the plan, the ES-DE note and the setup's label, which
|
||||
only the setups screen showed, were never seen."""
|
||||
setups = fresh_state().setups[:1]
|
||||
with mock.patch.object(install, "found_setups", return_value=(setups, set())), \
|
||||
mock.patch.object(install, "detect_frontends", return_value=["esde"]), \
|
||||
mock.patch.object(install, "_mounted_cards", return_value=[]), \
|
||||
mock.patch.object(install, "_DETECTION_REFUSED", []), \
|
||||
mock.patch.object(install.Path, "exists", return_value=True):
|
||||
state, _effects = install.tui_start(_args(), "linux")
|
||||
self.assertTrue(state.opened_on_plan)
|
||||
install.tui_receive(state, ("manifest", "retroarch", MANIFEST))
|
||||
for event in [*FILES, *SYSTEMS, CHECK]:
|
||||
install.tui_receive(state, ("event", state.run_id, event))
|
||||
with mock.patch.object(install, "_tui_folder_facts", return_value={
|
||||
"free": 50 * 1024 ** 3, "writable": True}):
|
||||
install.tui_receive(state, ("ended", state.run_id, 1, []))
|
||||
state.pad = "both"
|
||||
plan = screen_text(install.tui_view(state, 80, 24))
|
||||
self.assertIn("1 note on this machine: Details.", plan)
|
||||
self.assertNotIn(install.TUI_ESDE_NOTE, plan)
|
||||
# The plan's budget, plus the note line (6 words) and Change where (2).
|
||||
self.assertLessEqual(screen_words(install.tui_view(state, 80, 24)),
|
||||
SCREEN_WORDS["plan"][2] + 8)
|
||||
state.focus["plan"] = [row.action for row in install.tui_rows(state)].index("details")
|
||||
press(state, "enter")
|
||||
details = screen_text(install.tui_view(state, 120, 40))
|
||||
self.assertIn(screen_text([install.TUI_ESDE_NOTE]), details)
|
||||
self.assertIn("Found here: RetroArch.", details)
|
||||
|
||||
def test_several_setups_open_the_list(self):
|
||||
with mock.patch.object(install, "found_setups", return_value=(
|
||||
fresh_state().setups, set())), \
|
||||
|
||||
+17
-5
@@ -172,7 +172,8 @@ Run at a terminal, the installer opens a full screen that a keyboard or a
|
||||
controller drives. With one setup found here, it opens on that setup's plan,
|
||||
and two presses of A or Enter install everything it reads: the plan, then the
|
||||
result. **Change where** (or B) goes back to the setups, which open first when
|
||||
none or several are found. After an install, the plan of the same folder opens
|
||||
none or several are found. What that screen would have noted (ES-DE found, the
|
||||
setup's name) is under **Details**, one plan line saying so. After an install, the plan of the same folder opens
|
||||
on the same choice (**Same choice as on 10 Nov: yes**, the
|
||||
[install record](#the-install-record)), and **no** takes the whole list, or the
|
||||
games of the card when it holds some. The plan screen says
|
||||
@@ -489,10 +490,21 @@ the choice (`--system`, `--core`, `--region`, `--for-games`, `--no-game-data`,
|
||||
`--target`, `--every-board`, `--emulator`, `--installed-cores`, `--declared-only`)
|
||||
replaces it, `--all-files` installs the whole list, and a run over several
|
||||
setups or from a file list (`--manifest`) never reads it. `--check` and
|
||||
`--dry-run` read it too, and say so. To forget it, delete the file. A record
|
||||
that cannot be read, or that another version wrote, is said once and ignored,
|
||||
and the next install writes a new one. A record kept for a folder of another
|
||||
name (copied or renamed with it) is not applied. With `--events` the record
|
||||
`--dry-run` read it too, and say so. To forget it, delete the file.
|
||||
|
||||
A record is read only when it is a regular file of at most 16 MiB whose
|
||||
choice holds nothing but the options above and their values. A games folder
|
||||
(`--for-games FOLDER`) is kept only under the folder that holds the BIOS
|
||||
folder, so a record never sends the games scan to another drive or a network
|
||||
share: any other folder is recorded as `--for-games` alone, the card's own
|
||||
games. A record that breaks any of this, or cannot be read, is said once and
|
||||
ignored, and the next install writes a new one, keeping the other setups'
|
||||
entries that read. One written by a newer installer is left as it is. A
|
||||
recorded option the list can no longer apply (a region, an emulator, a board,
|
||||
the installed cores, game data on a list that marks none) is left out in one
|
||||
line, `Left out of the recorded choice: --target (...)`, and the run goes on:
|
||||
typed on the command line, the same option is refused as before. A record kept
|
||||
for a folder of another name (copied or renamed with it) is not applied. With `--events` the record
|
||||
gives a `record` event, see [Machine-readable events](#machine-readable-events).
|
||||
|
||||
## Reading a run { #reading-a-run }
|
||||
|
||||
Reference in new issue
Block a user