mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-01 00:27:20 -05:00
feat: put the retrobios app on a handheld card
--add-app, the result screen and a [y/N] question put the launcher of muOS, KNULLI or ROCKNIX on the card the BIOS folder is on. The files come from install/launchers/index.json at the same ref, each checked by size and SHA-256 before the first is written, and the app event reports it.
This commit is contained in:
1 parent
fbe5ba4e3e
commit
f3c816eea5
10 files changed
+1622
-12
No files matched your search
+1
-1
@@ -11,7 +11,7 @@
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
|
||||
$defaultInstallSha256 = "be9d37048e800072d6d5d2a40afaa5a46d5b062577f83c0fa633fc15159de66a"
|
||||
$defaultInstallSha256 = "cec348d9aa6c9f940a2c9dc9bc6414a923acea8137e4a3624d4710c9d88c38ad"
|
||||
$maximumInstallerBytes = 2MB
|
||||
$installerExit = 0
|
||||
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
|
||||
|
||||
+518
-8
@@ -573,6 +573,12 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = {
|
||||
# would move), one per backup folder, empty when none.
|
||||
"summary": ("mode", "installed", "up_to_date", "pending", "failed", "skipped",
|
||||
"left_out", "kept", "interrupted"),
|
||||
# --add-app, or yes to its question, after the summary: the RetroBIOS
|
||||
# app put on the handheld card (folder, its root) of system. dests are
|
||||
# the app's files below the root, written those this run wrote (none
|
||||
# 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"),
|
||||
"end": ("code",),
|
||||
}
|
||||
# Seconds between two progress events while bytes arrive.
|
||||
@@ -8369,6 +8375,380 @@ def bulk_hint(
|
||||
return []
|
||||
|
||||
|
||||
# The RetroBIOS app on a handheld's card. A card prepared on a computer can
|
||||
# also carry the launcher of ports/ for its system, so the player checks and
|
||||
# updates the BIOS files from the console later. The files come from the
|
||||
# same ref as the file lists, listed with their hashes in
|
||||
# install/launchers/index.json, which scripts/generate_launcher_index.py
|
||||
# writes from the members of the launcher archives.
|
||||
LAUNCHERS_URL = f"{BASE_URL}/install/launchers/index.json"
|
||||
MAX_LAUNCHERS_BYTES = 256 * 1024
|
||||
MAX_APP_FILE_SIZE = 8 * 1024 * 1024
|
||||
MAX_APP_FILES = 64
|
||||
APP_SCHEMA = 1
|
||||
|
||||
|
||||
class AppPlace(NamedTuple):
|
||||
"""Where a system's launcher goes on its card, and what the player does next.
|
||||
|
||||
key names its entry in the launcher list, folder is below the card's
|
||||
root, executable says whether the menu runs the launcher script itself.
|
||||
"""
|
||||
|
||||
key: str
|
||||
folder: str
|
||||
executable: bool
|
||||
then: str
|
||||
|
||||
|
||||
APP_PLACES: dict[str, AppPlace] = {
|
||||
# The Archive Manager lists the .muxapp files of ARCHIVE at the root of
|
||||
# SD1, SD2 and a USB drive, and installs one into Applications
|
||||
# (MustardOS/frontend 9c0fb5da, module/muxarchive.c:16,157-164,196-218,
|
||||
# MUOS_ARCH_PATH "ARCHIVE" in common/base/options.h:180). The archive
|
||||
# carries the modes of its members.
|
||||
"muOS": AppPlace("muos", "ARCHIVE", False,
|
||||
"Then open the Archive Manager on the console, install RetroBIOS, "
|
||||
"and start it from Applications."),
|
||||
# The card's root is the SHARE partition, /userdata on the console.
|
||||
# Ports runs a .sh with /bin/bash (knulli-cfw distribution ab3a6809,
|
||||
# configgen/generators/sh/shGenerator.py:19-35), and evmapy reads the
|
||||
# .keys file beside it: no mode is needed on the card.
|
||||
"KNULLI": AppPlace("knulli", "roms/ports", False,
|
||||
"Then update the game list on the console, and start RetroBIOS "
|
||||
"from Ports."),
|
||||
# The card's root is the STORAGE partition, /storage on the console. Tools
|
||||
# runs each file of /storage/.config/modules in foot or weston-terminal,
|
||||
# which starts it as a program: the build installs them 0755 (ROCKNIX
|
||||
# distribution 8b70f693, projects/ROCKNIX/packages/virtual/emulators/
|
||||
# package.mk:118-122 and 1701-1707).
|
||||
"ROCKNIX": AppPlace("rocknix", ".config/modules", True,
|
||||
"Then start RetroBIOS from Tools on the console."),
|
||||
}
|
||||
|
||||
# Card systems with no Python 3 to run the installer: Onion ships Python 2.7
|
||||
# alone (OnionUI/Onion 07505ea5, static/build/.tmp_update/bin/parasyte/),
|
||||
# MinUI (dbf89435), NextUI (5291344b) and Allium (ee0dda97) none, and
|
||||
# MyMinUI is built from MinUI.
|
||||
NO_PYTHON_CARDS = frozenset(("Onion", "Allium", "MinUI", "NextUI", "MyMinUI"))
|
||||
NO_PYTHON_WORDS = ("This console cannot run RetroBIOS itself: to update its BIOS files "
|
||||
"later, run this again on the computer.")
|
||||
APP_OFFER = "Put RetroBIOS on this card (update from the console later)"
|
||||
|
||||
|
||||
class CardApp(NamedTuple):
|
||||
"""A card a BIOS folder is on, and whether the RetroBIOS app can go there."""
|
||||
|
||||
root: Path
|
||||
system: str
|
||||
place: AppPlace | None
|
||||
why: str
|
||||
|
||||
|
||||
def card_app(bios_dir: Path) -> CardApp | None:
|
||||
"""The card bios_dir is the BIOS folder of, None for a folder on no card
|
||||
the installer recognises. why says, when place is None, why the app
|
||||
cannot go on it."""
|
||||
folder = Path(os.path.abspath(bios_dir))
|
||||
for root in [folder, *list(folder.parents)[:3]]:
|
||||
found = card_system(root)
|
||||
if found is None or Path(os.path.abspath(found[1])) != folder:
|
||||
continue
|
||||
system = found[2]
|
||||
place = APP_PLACES.get(system)
|
||||
if place is not None:
|
||||
return CardApp(root, system, place, "")
|
||||
why = (f"{system} has no Python to run RetroBIOS" if system in NO_PYTHON_CARDS
|
||||
else f"there is no RetroBIOS app for {system} yet")
|
||||
return CardApp(root, system, None, why)
|
||||
return None
|
||||
|
||||
|
||||
def _app_entry(value: object, field: str, source: bool = True) -> dict:
|
||||
"""One file of the launcher list, checked as a manifest entry is."""
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("an entry is not an object")
|
||||
_safe_relative_path(value.get(field), field)
|
||||
size = value.get("size")
|
||||
if type(size) is not int or not 0 < size <= MAX_APP_FILE_SIZE:
|
||||
raise ValueError(f"{value.get(field)}: invalid size")
|
||||
digest = value.get("sha256")
|
||||
if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest):
|
||||
raise ValueError(f"{value.get(field)}: invalid sha256")
|
||||
if not isinstance(value.get("executable", False), bool):
|
||||
raise ValueError(f"{value.get(field)}: invalid executable")
|
||||
if source:
|
||||
repo, content = value.get("repo_path"), value.get("content")
|
||||
if (repo is None) == (content is None):
|
||||
raise ValueError(f"{value.get(field)}: needs repo_path or content")
|
||||
if repo is not None:
|
||||
_safe_relative_path(repo, "repo_path")
|
||||
elif not isinstance(content, str):
|
||||
raise ValueError(f"{value.get(field)}: invalid content")
|
||||
return value
|
||||
|
||||
|
||||
def app_targets(data: object, place: AppPlace) -> list[dict]:
|
||||
"""The files the launcher list puts on a card for place, each under its
|
||||
folder: an archive carries its members, the others their source.
|
||||
|
||||
The list comes from the same base as the file lists, and is held to
|
||||
the folder its system reads: no entry writes elsewhere on the card.
|
||||
"""
|
||||
if not isinstance(data, dict) or data.get("schema") != APP_SCHEMA:
|
||||
raise ValueError(f"not a launcher list of schema {APP_SCHEMA}")
|
||||
launchers = data.get("launchers")
|
||||
launcher = launchers.get(place.key) if isinstance(launchers, dict) else None
|
||||
if not isinstance(launcher, dict):
|
||||
raise ValueError(f"it holds no launcher for {place.key}")
|
||||
if "archive" in launcher:
|
||||
target = dict(_app_entry(launcher["archive"], "dest", source=False))
|
||||
members = launcher.get("members")
|
||||
if not isinstance(members, list) or not 0 < len(members) <= MAX_APP_FILES:
|
||||
raise ValueError("invalid members")
|
||||
target["members"] = [_app_entry(member, "name") for member in members]
|
||||
if len({member["name"] for member in target["members"]}) != len(members):
|
||||
raise ValueError("two members share a name")
|
||||
targets = [target]
|
||||
else:
|
||||
files = launcher.get("files")
|
||||
if not isinstance(files, list) or not 0 < len(files) <= MAX_APP_FILES:
|
||||
raise ValueError("invalid files")
|
||||
targets = [_app_entry(entry, "dest") for entry in files]
|
||||
prefix = PurePosixPath(place.folder).parts
|
||||
for target in targets:
|
||||
parts = PurePosixPath(target["dest"]).parts
|
||||
if len(parts) <= len(prefix) or parts[:len(prefix)] != prefix:
|
||||
raise ValueError(f"{target['dest']} is outside {place.folder}")
|
||||
if len({target["dest"].casefold() for target in targets}) != len(targets):
|
||||
raise ValueError("two files share a destination")
|
||||
return targets
|
||||
|
||||
|
||||
def app_archive(members: list[tuple[dict, bytes]]) -> bytes:
|
||||
"""A launcher archive from its members: stored, sorted, dated 1980-01-01,
|
||||
only the executable bit kept, so the same members give the same bytes
|
||||
on any machine and the list can record their hash."""
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_STORED) as archive:
|
||||
for entry, data in sorted(members, key=lambda member: member[0]["name"]):
|
||||
info = zipfile.ZipInfo(entry["name"], date_time=(1980, 1, 1, 0, 0, 0))
|
||||
info.compress_type = zipfile.ZIP_STORED
|
||||
info.create_system = 3
|
||||
mode = 0o755 if entry.get("executable") else 0o644
|
||||
info.external_attr = (0o100000 | mode) << 16
|
||||
archive.writestr(info, data)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _app_download(url: str, size: int) -> bytes:
|
||||
"""The bytes at url, tried MAX_RETRIES times, never more than size + 1."""
|
||||
_network_allowed("the RetroBIOS app")
|
||||
for attempt in range(1, MAX_RETRIES + 1):
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=60) as resp:
|
||||
return resp.read(size + 1)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code < 500 and exc.code != 429 or attempt == MAX_RETRIES:
|
||||
raise
|
||||
except (urllib.error.URLError, OSError, http.client.HTTPException):
|
||||
if attempt == MAX_RETRIES:
|
||||
raise
|
||||
time.sleep(_retry_delay(attempt, None))
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
|
||||
def _app_bytes(entry: dict, name: str) -> bytes:
|
||||
"""An entry's bytes, from the list itself or the repository, once they
|
||||
have the size and the SHA-256 the list records."""
|
||||
content = entry.get("content")
|
||||
if content is not None:
|
||||
data = content.encode("utf-8")
|
||||
else:
|
||||
url = RAW_FILE_URL.format(path=urllib.parse.quote(entry["repo_path"], safe="/"))
|
||||
data = _app_download(url, entry["size"])
|
||||
if len(data) != entry["size"]:
|
||||
raise ValueError(f"{name} holds {len(data)} bytes, the list records {entry['size']}")
|
||||
if hashlib.sha256(data).hexdigest() != entry["sha256"]:
|
||||
raise ValueError(f"{name}: SHA-256 mismatch")
|
||||
return data
|
||||
|
||||
|
||||
def _target_bytes(target: dict) -> bytes:
|
||||
"""What goes at a target's place: its file, or its archive built from
|
||||
its members and checked against the hash the list records for it."""
|
||||
members = target.get("members")
|
||||
if members is None:
|
||||
return _app_bytes(target, target["dest"])
|
||||
data = app_archive([(member, _app_bytes(member, member["name"])) for member in members])
|
||||
if len(data) != target["size"] or hashlib.sha256(data).hexdigest() != target["sha256"]:
|
||||
raise ValueError(f"{target['dest']} built from its members is not the one the "
|
||||
"list records")
|
||||
return data
|
||||
|
||||
|
||||
def _app_in_place(path: Path, target: dict) -> bool:
|
||||
try:
|
||||
return (path.is_file() and path.stat().st_size == target["size"]
|
||||
and _sha256_file(path) == target["sha256"])
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _app_failure(exc: BaseException) -> str:
|
||||
if isinstance(exc, urllib.error.HTTPError):
|
||||
return f"the server answered HTTP {exc.code}"
|
||||
if isinstance(exc, ValueError):
|
||||
return str(exc)
|
||||
if isinstance(exc, _Offline) or _transfer_failure(exc):
|
||||
return network_words(exc)[1]
|
||||
return getattr(exc, "strerror", None) or str(exc)
|
||||
|
||||
|
||||
def put_app(card: CardApp) -> dict:
|
||||
"""Put the launcher of card's system on it: the fields of the app event.
|
||||
|
||||
Every file is fetched and verified before the first one is written, and
|
||||
written as a BIOS file is, the one in place kept aside when it differs.
|
||||
A file already in place with the recorded hash is left alone, so a
|
||||
second run writes nothing.
|
||||
"""
|
||||
fields: dict = {"system": card.system, "folder": str(card.root), "ok": False,
|
||||
"reason": card.why, "dests": [], "written": [], "kept": []}
|
||||
if card.place is None:
|
||||
return fields
|
||||
try:
|
||||
data = _fetch_json(LAUNCHERS_URL, MAX_LAUNCHERS_BYTES, "list of the app's files")
|
||||
targets = app_targets(data, card.place)
|
||||
except (urllib.error.URLError, OSError, ValueError, http.client.HTTPException) as exc:
|
||||
fields["reason"] = f"its list of files could not be read: {_app_failure(exc)}"
|
||||
return fields
|
||||
fields["dests"] = [target["dest"] for target in targets]
|
||||
places = [(target, _destination_path(card.root, target["dest"])) for target in targets]
|
||||
needed = [(target, path) for target, path in places if not _app_in_place(path, target)]
|
||||
try:
|
||||
payloads = [_target_bytes(target) for target, _path in needed]
|
||||
except (urllib.error.URLError, OSError, ValueError, http.client.HTTPException) as exc:
|
||||
fields["reason"] = f"it could not be downloaded: {_app_failure(exc)}"
|
||||
return fields
|
||||
before = {item["folder"]: (item["files"], item["bytes"]) for item in KEPT.summary()}
|
||||
for (target, path), payload in zip(needed, payloads):
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
fields["reason"] = NOT_WRITABLE if _write_refused(exc) else _app_failure(exc)
|
||||
break
|
||||
_dest, ok, why = _write_verified(
|
||||
target, card.root, path, lambda payload=payload: io.BytesIO(payload), None,
|
||||
"the RetroBIOS app")
|
||||
if not ok:
|
||||
fields["reason"] = f"{target['dest']}: {why}"
|
||||
break
|
||||
fields["written"].append(target["dest"])
|
||||
else:
|
||||
fields["ok"], fields["reason"] = True, ""
|
||||
for item in KEPT.summary():
|
||||
files, size = before.get(item["folder"], (0, 0))
|
||||
if item["files"] > files:
|
||||
fields["kept"].append({"folder": item["folder"], "files": item["files"] - files,
|
||||
"bytes": item["bytes"] - size})
|
||||
return fields
|
||||
|
||||
|
||||
def app_lines(card: CardApp, fields: dict) -> list[str]:
|
||||
"""What putting the app on the card did, in words."""
|
||||
if not fields["ok"]:
|
||||
return [f"RetroBIOS could not be put on the {card.system} card: {fields['reason']}."]
|
||||
if fields["written"]:
|
||||
lines = [f"RetroBIOS is on the {card.system} card: "
|
||||
f"{_plural(len(fields['written']), 'file')} written in {card.root}."]
|
||||
else:
|
||||
lines = [f"RetroBIOS is already on the {card.system} card: nothing to write."]
|
||||
lines += [f"The files it replaced are kept in {kept['folder']}" for kept in fields["kept"]]
|
||||
if card.place is not None:
|
||||
lines.append(card.place.then)
|
||||
return lines
|
||||
|
||||
|
||||
def app_refusal(args: argparse.Namespace, platforms: list[tuple[str, Path]]) -> list[CardApp]:
|
||||
"""The cards --add-app puts the app on, one per folder. A folder that is
|
||||
on no card the app can go on is refused with the reason, code 2, before
|
||||
anything is written."""
|
||||
cards: list[CardApp] = []
|
||||
if not args.add_app:
|
||||
return cards
|
||||
for _plat, path in platforms:
|
||||
card = card_app(path)
|
||||
if card is None:
|
||||
why = (f"{path} is not on a card this installer recognises (a muOS, "
|
||||
"KNULLI or ROCKNIX card)")
|
||||
else:
|
||||
why = card.why
|
||||
if why:
|
||||
EVENTS.emit("app", system=card.system if card else "",
|
||||
folder=str(card.root if card else path), ok=False, reason=why,
|
||||
dests=[], written=[], kept=[])
|
||||
print(f"Error: --add-app puts the RetroBIOS app on a handheld's SD card, and "
|
||||
f"{why}.", file=sys.stderr)
|
||||
if card is not None and card.system in NO_PYTHON_CARDS:
|
||||
print(f" {NO_PYTHON_WORDS}", file=sys.stderr)
|
||||
else:
|
||||
print(" Name the card's root or its BIOS folder with --dest.",
|
||||
file=sys.stderr)
|
||||
sys.exit(2)
|
||||
if card not in cards:
|
||||
cards.append(card)
|
||||
_APP_PENDING[:] = cards
|
||||
return cards
|
||||
|
||||
|
||||
# The cards --add-app named, until the install finishes and the app is put.
|
||||
_APP_PENDING: list[CardApp] = []
|
||||
|
||||
|
||||
def app_not_added() -> None:
|
||||
"""--add-app in a run that stopped before the app: said, never silent."""
|
||||
cards, _APP_PENDING[:] = list(_APP_PENDING), []
|
||||
for card in cards:
|
||||
EVENTS.emit("app", system=card.system, folder=str(card.root), ok=False,
|
||||
reason="the install did not finish", dests=[], written=[], kept=[])
|
||||
print(f" RetroBIOS was not put on the {card.system} card: the install did "
|
||||
"not finish.", file=sys.stderr)
|
||||
|
||||
|
||||
def finish_app(
|
||||
args: argparse.Namespace, platforms: list[tuple[str, Path]], cards: list[CardApp],
|
||||
interactive: bool,
|
||||
) -> None:
|
||||
"""After an install that finished: the app on the cards --add-app named,
|
||||
or on those the person says yes for at a terminal."""
|
||||
_APP_PENDING[:] = []
|
||||
chosen = list(cards)
|
||||
if not args.add_app:
|
||||
for card in dict.fromkeys(filter(None, (card_app(path) for _plat, path in platforms))):
|
||||
if card.place is None:
|
||||
if card.system in NO_PYTHON_CARDS:
|
||||
print(f"\n{NO_PYTHON_WORDS}")
|
||||
continue
|
||||
if not interactive or args.yes or LOCAL.offline:
|
||||
continue
|
||||
answer = _ask(f"\nPut RetroBIOS on this {card.system} card, to check and update "
|
||||
"its BIOS files from the console later? [y/N] ")
|
||||
if answer.lower() in ("y", "yes"):
|
||||
chosen.append(card)
|
||||
failed = False
|
||||
for card in chosen:
|
||||
print(f"\nPutting RetroBIOS on the {card.system} card...")
|
||||
fields = put_app(card)
|
||||
EVENTS.emit("app", **fields)
|
||||
stream = sys.stdout if fields["ok"] else sys.stderr
|
||||
for line in app_lines(card, fields):
|
||||
print(f" {line}", file=stream)
|
||||
failed = failed or not fields["ok"]
|
||||
if failed:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _help_epilog() -> str:
|
||||
"""Examples in the form the run was started with, and where the rest is."""
|
||||
run = _INVOCATION.launcher()
|
||||
@@ -8451,6 +8831,13 @@ def main() -> None:
|
||||
action="store_true",
|
||||
help="opt in to copies into detected standalone-emulator directories",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--add-app",
|
||||
action="store_true",
|
||||
help="after the install, put the RetroBIOS app on the handheld card the BIOS "
|
||||
"folder is on (muOS, KNULLI, ROCKNIX), to check and update the BIOS "
|
||||
"files from the console later",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--emulator",
|
||||
action="append",
|
||||
@@ -8680,7 +9067,7 @@ def main() -> None:
|
||||
"cores_dir", "save_dir", "system", "core", "region", "no_game_data",
|
||||
"declared_only", "list_systems",
|
||||
"list_cores", "romm_firmware", "list_emulators", "events", "quiet",
|
||||
"yes", "verbose", "from_pack", "manifest", "from")
|
||||
"yes", "verbose", "from_pack", "manifest", "from", "add_app")
|
||||
given = [name for name in alone if getattr(args, name)]
|
||||
if args.jobs != 8:
|
||||
given.append("jobs")
|
||||
@@ -8690,6 +9077,22 @@ def main() -> None:
|
||||
sys.exit(restore_kept(args.restore, args.dry_run))
|
||||
if args.every_board and args.target:
|
||||
parser.error("--every-board installs every board; --target names one")
|
||||
if args.add_app:
|
||||
# Applied or refused, never ignored.
|
||||
for given, reason in (
|
||||
(args.check or args.dry_run,
|
||||
"--add-app writes the RetroBIOS app on the card, which --check and "
|
||||
"--dry-run do not"),
|
||||
(args.list_platforms or args.list_targets or args.list_systems
|
||||
or args.list_cores or args.list_emulators,
|
||||
"--add-app writes the RetroBIOS app on the card, which a listing does not"),
|
||||
(args.from_pack, "--add-app downloads the RetroBIOS app, and --from-pack "
|
||||
"makes no network request"),
|
||||
(args.offline or offline_requested(), "--add-app downloads the RetroBIOS "
|
||||
"app, which --offline does not"),
|
||||
):
|
||||
if given:
|
||||
parser.error(reason)
|
||||
if args.offline:
|
||||
# The runs the full-screen interface starts read it from here.
|
||||
os.environ[OFFLINE_VARIABLE] = "1"
|
||||
@@ -8741,6 +9144,13 @@ def _run(args: argparse.Namespace) -> None:
|
||||
"""The run, its text quiet or not, the archives it read closed after."""
|
||||
try:
|
||||
_run_text(args)
|
||||
except SystemExit as exc:
|
||||
if exc.code not in (0, None):
|
||||
app_not_added()
|
||||
raise
|
||||
except KeyboardInterrupt:
|
||||
app_not_added()
|
||||
raise
|
||||
finally:
|
||||
LOCAL.close_indexes()
|
||||
|
||||
@@ -9318,6 +9728,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
# --check installs nothing, and "Install for:" said it would.
|
||||
platforms = _prompt_platform_choice(
|
||||
platforms, "Check:" if args.check else "Install for:")
|
||||
# --add-app names a card for each folder, or the run stops here.
|
||||
app_cards = app_refusal(args, platforms)
|
||||
|
||||
if args.dest is not None and not listing:
|
||||
_missing_dest(args.dest, interactive and not (args.check or args.dry_run or args.yes))
|
||||
@@ -9922,6 +10334,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
hints=bulk_lines,
|
||||
sources=_run_sources(bios_indexes, total_downloaded, total_errors),
|
||||
)
|
||||
finish_app(args, platforms, app_cards, interactive and not total_declined)
|
||||
|
||||
|
||||
def _run_sources(bios_indexes: list[FolderIndex], installed: int, failed: int) -> str:
|
||||
@@ -10340,6 +10753,14 @@ class TuiState:
|
||||
self.tail: list = []
|
||||
self.log: str = ""
|
||||
self.exit_code: int | None = None
|
||||
# The RetroBIOS app on the card the BIOS folder is on: asked for
|
||||
# (--add-app, the options), what putting it did (the app event),
|
||||
# and the card, read once per folder.
|
||||
self.add_app: bool = False
|
||||
self.app: dict | None = None
|
||||
self.app_running: bool = False
|
||||
self.card_for: Path | None = None
|
||||
self.card: CardApp | None = None
|
||||
|
||||
@property
|
||||
def screen(self) -> str:
|
||||
@@ -10503,6 +10924,9 @@ def tui_argv(state: TuiState, run: str, python: str, script: str) -> list[str]:
|
||||
if state.manifest_path is not None:
|
||||
argv.append(f"--manifest={state.manifest_path}")
|
||||
argv += [flag if value is None else f"{flag}={value}" for flag, value in tui_flags(state)]
|
||||
if run == "install" and state.add_app and _tui_app_ready(state):
|
||||
# Only the install writes: --check and --dry-run refuse it.
|
||||
argv.append("--add-app")
|
||||
return argv + _TUI_MODES[run]
|
||||
|
||||
|
||||
@@ -10513,8 +10937,40 @@ def tui_command(state: TuiState) -> str:
|
||||
hint.options = ([("--manifest", str(state.manifest_path))]
|
||||
if state.manifest_given and state.manifest_path else [])
|
||||
hint.options += tui_flags(state)
|
||||
if state.add_app and _tui_app_ready(state):
|
||||
hint.options.append(("--add-app", None))
|
||||
return hint.command(state.platform, state.dest)
|
||||
|
||||
|
||||
def _tui_card(state: TuiState) -> CardApp | None:
|
||||
"""The card the BIOS folder is on, read once for each folder."""
|
||||
if state.dest is None:
|
||||
return None
|
||||
if state.card_for != state.dest:
|
||||
state.card_for, state.card = state.dest, card_app(state.dest)
|
||||
return state.card
|
||||
|
||||
|
||||
def _tui_app_ready(state: TuiState) -> bool:
|
||||
"""Whether the RetroBIOS app can go on this card: its system has one,
|
||||
and the run may use the network to fetch it."""
|
||||
card = _tui_card(state)
|
||||
return card is not None and card.place is not None and not LOCAL.offline
|
||||
|
||||
|
||||
def _tui_installed(state: TuiState) -> bool:
|
||||
"""An install that finished with every file in place."""
|
||||
summary = state.summary or {}
|
||||
return bool(summary) and not summary.get("interrupted") and not summary.get("failed") \
|
||||
and state.code == 0
|
||||
|
||||
|
||||
def _app_offer(state: TuiState) -> bool:
|
||||
"""The result offers the app: the install finished, the card can take
|
||||
it, and it was neither asked for nor put already."""
|
||||
return (state.screen == "result" and state.app is None and not state.app_running
|
||||
and _tui_installed(state) and _tui_app_ready(state))
|
||||
|
||||
class TuiRow(NamedTuple):
|
||||
"""One row a screen lists: what Enter does on it, and what it shows."""
|
||||
|
||||
@@ -10665,6 +11121,9 @@ def tui_rows(state: TuiState) -> list[TuiRow]:
|
||||
TuiRow("quit", "Quit")]
|
||||
if screen == "quit":
|
||||
return [TuiRow("quit", "Quit"), TuiRow("stay", "Stay")]
|
||||
if screen == "result" and _app_offer(state):
|
||||
# Exit first: the app is put only when it is chosen.
|
||||
return [TuiRow("exit", "Exit"), TuiRow("addapp", APP_OFFER)]
|
||||
return []
|
||||
|
||||
|
||||
@@ -10794,6 +11253,8 @@ def _option_rows(state: TuiState) -> list[TuiRow]:
|
||||
TuiRow("folder", f"BIOS folder: {_short_path(state.dest, state.home)}", "--dest",
|
||||
style="path"),
|
||||
TuiRow("from", f"Copy from: {copied}", "--from", style="path"),
|
||||
*([TuiRow("addapp", f"Put RetroBIOS on this card: {_yes(state.add_app)}",
|
||||
"--add-app")] if _tui_app_ready(state) else []),
|
||||
TuiRow("preview", "Preview the file list", "--dry-run"),
|
||||
TuiRow("command", "Show the command line"),
|
||||
]
|
||||
@@ -11158,6 +11619,11 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]:
|
||||
if action == "quit":
|
||||
return tui_quit(state)
|
||||
return _back(state)
|
||||
if screen == "result":
|
||||
if action == "addapp":
|
||||
state.add_app = state.app_running = True
|
||||
return [("app", _tui_card(state))]
|
||||
return tui_quit(state)
|
||||
return []
|
||||
|
||||
|
||||
@@ -11185,6 +11651,8 @@ def _option(state: TuiState, action: str, step: int) -> list[tuple]:
|
||||
state.installed_only = step > 0 if step else not state.installed_only
|
||||
elif action == "jobs":
|
||||
state.jobs = max(1, min(32, state.jobs + (step or 1)))
|
||||
elif action == "addapp":
|
||||
state.add_app = step > 0 if step else not state.add_app
|
||||
elif action == "cores":
|
||||
# On the first core: the filter row under A opened a field a
|
||||
# controller cannot type into.
|
||||
@@ -11388,6 +11856,9 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]:
|
||||
if state.help:
|
||||
state.help = False
|
||||
return []
|
||||
if state.app_running:
|
||||
# The app is being written to the card: it finishes first.
|
||||
return []
|
||||
state.status = ""
|
||||
screen = state.screen
|
||||
if screen == "path":
|
||||
@@ -11411,9 +11882,9 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]:
|
||||
return []
|
||||
if char == "q":
|
||||
return tui_quit(state)
|
||||
if screen in _PAGERS:
|
||||
return _pager_key(state, key)
|
||||
rows = tui_rows(state)
|
||||
if screen in _PAGERS and not rows:
|
||||
return _pager_key(state, key)
|
||||
if not rows:
|
||||
return _back(state) if key.name in ("esc", "backspace") else []
|
||||
focus = max(0, min(state.focus.get(screen, 0), len(rows) - 1))
|
||||
@@ -11441,6 +11912,8 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]:
|
||||
rows = tui_rows(state)
|
||||
focus = next((i for i, item in enumerate(rows)
|
||||
if item.action == row.action and item.value == row.value), focus)
|
||||
elif name in ("esc", "backspace") and screen == "result":
|
||||
return tui_quit(state)
|
||||
elif name in ("esc", "backspace"):
|
||||
return _back(state)
|
||||
elif (name == "tab" or char == "o") and screen in ("plan", "systems", "confirm"):
|
||||
@@ -11467,7 +11940,7 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]:
|
||||
return _activate(state, rows[0 if char == "a" else 1])
|
||||
elif screen == "options" and name in ("left", "right"):
|
||||
return _option(state, row.action, 1 if name == "right" else -1) \
|
||||
if row.action in ("game_data", "installed", "jobs") \
|
||||
if row.action in ("game_data", "installed", "jobs", "addapp") \
|
||||
or (row.action == "from" and name == "left") else []
|
||||
elif char == " ":
|
||||
return _space(state, row)
|
||||
@@ -11502,7 +11975,7 @@ def _miyoo_key(state: TuiState, key: TuiKey) -> TuiKey:
|
||||
|
||||
# Rows Space leaves alone: it is X (tick) on PortMaster and Y on muxterm, a
|
||||
# key pressed for something else, and these end the run or write.
|
||||
_SPACE_INERT = ("install", "close", "quit")
|
||||
_SPACE_INERT = ("install", "close", "quit", "exit", "addapp")
|
||||
|
||||
|
||||
def _space(state: TuiState, row: TuiRow) -> list[tuple]:
|
||||
@@ -11522,6 +11995,9 @@ def _space(state: TuiState, row: TuiRow) -> list[tuple]:
|
||||
|
||||
def _take_event(state: TuiState, event: dict) -> None:
|
||||
kind = event.get("event")
|
||||
if kind == "app" and state.run == "install":
|
||||
state.app = event
|
||||
return
|
||||
if kind == "checking" and state.run in ("check", "install"):
|
||||
state.reading = (event["files_done"], event["files_total"])
|
||||
elif kind == "found" and state.run == "check":
|
||||
@@ -11578,6 +12054,12 @@ def tui_receive(state: TuiState, message: tuple) -> list[tuple]:
|
||||
return []
|
||||
state.manifest, state.manifest_for = manifest, plat
|
||||
return _start_run(state, "check")
|
||||
if kind == "app":
|
||||
# Put from the result screen, in this process.
|
||||
state.app, state.app_running = message[1], False
|
||||
if not state.app["ok"] and state.code == 0:
|
||||
state.code = 1
|
||||
return []
|
||||
if kind == "manifest_failed":
|
||||
_kind, plat, title, lines, code = message
|
||||
if plat != state.platform or state.screen != "checking":
|
||||
@@ -11707,7 +12189,7 @@ _ROW_VERBS = {
|
||||
"preview": "Open", "folder": "Choose", "game_data": "Change", "installed": "Change",
|
||||
"jobs": "Change", "retry": "Try again", "back": "Back", "stay": "Stay",
|
||||
"fromcard": "Choose", "from": "Add a folder", "misplaced": "Show", "zip": "Copy from it",
|
||||
"unreadable": "Show",
|
||||
"unreadable": "Show", "exit": "Exit", "addapp": "Put it on the card",
|
||||
}
|
||||
|
||||
|
||||
@@ -12463,12 +12945,27 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]:
|
||||
for group in summary.get("left_out") or []:
|
||||
lines.append(f"Left out as {group['label']}: {_plural(group['files'], 'file')}.")
|
||||
lines += _unplaced_lines(summary, state)
|
||||
lines += _tui_app_lines(state)
|
||||
lines += ["Same choice without questions:", f" {tui_command(state)}"]
|
||||
if tui_keeps_log(state):
|
||||
lines.append(f"Details: {state.log}")
|
||||
return title, lines
|
||||
|
||||
|
||||
def _tui_app_lines(state: TuiState) -> list[str]:
|
||||
"""What became of the RetroBIOS app on the card, once the install finished."""
|
||||
card = _tui_card(state)
|
||||
if card is None:
|
||||
return []
|
||||
if state.app_running:
|
||||
return ["Putting RetroBIOS on the card..."]
|
||||
if state.app is not None:
|
||||
return app_lines(card, state.app)
|
||||
if card.place is None and card.system in NO_PYTHON_CARDS and _tui_installed(state):
|
||||
return [NO_PYTHON_WORDS]
|
||||
return []
|
||||
|
||||
|
||||
def tui_keeps_log(state: TuiState) -> bool:
|
||||
"""Whether the run's log is kept: when a failure names it, and only then.
|
||||
57 folders of logs from stopped and finished runs were left in /tmp."""
|
||||
@@ -12481,6 +12978,12 @@ def tui_keeps_log(state: TuiState) -> bool:
|
||||
|
||||
def _view_result(state: TuiState, width: int, height: int) -> tuple:
|
||||
title, lines = tui_outcome(state)
|
||||
rows = tui_rows(state)
|
||||
if rows:
|
||||
return (title, _compose(state, width, height, lines, rows),
|
||||
("A/Enter Choose", "B/Esc Exit"))
|
||||
if state.app_running:
|
||||
return (title, _pager(state, width, height, lines), ("Writing to the card...",))
|
||||
return (title, _pager(state, width, height, lines), ("A/Enter Exit", "Up/Down Scroll"))
|
||||
|
||||
|
||||
@@ -12595,8 +13098,6 @@ def _help_lines(state: TuiState) -> list[tuple[str, str]]:
|
||||
|
||||
def _primary(state: TuiState) -> str | None:
|
||||
"""What A does on the row in focus, for the first entry of the bar."""
|
||||
if state.screen in _PAGERS:
|
||||
return None
|
||||
rows = tui_rows(state)
|
||||
if not rows:
|
||||
return None
|
||||
@@ -12709,6 +13210,7 @@ def tui_start(
|
||||
state.no_game_data = bool(args.no_game_data)
|
||||
state.installed_only = bool(args.installed_cores)
|
||||
state.jobs = args.jobs
|
||||
state.add_app = bool(getattr(args, "add_app", False))
|
||||
state.passthrough = _tui_passthrough(args)
|
||||
if args.manifest is not None:
|
||||
state.manifest_path = Path(args.manifest).absolute()
|
||||
@@ -12853,6 +13355,11 @@ TUI_FETCH_TIMEOUT = 15
|
||||
TUI_OFFLINE_TITLE = "No internet: offline mode"
|
||||
|
||||
|
||||
def _tui_put_app(card: CardApp, inbox: queue.Queue) -> None:
|
||||
"""The app put on the card in this process, what it did sent back."""
|
||||
inbox.put(("app", put_app(card)))
|
||||
|
||||
|
||||
def _tui_fetch(plat: str, inbox: queue.Queue, log: io.StringIO) -> None:
|
||||
"""The file list, fetched once in this process, or what went wrong."""
|
||||
if LOCAL.manifest is not None or LOCAL.pack is not None:
|
||||
@@ -13234,6 +13741,9 @@ def run_tui(args: argparse.Namespace) -> int:
|
||||
elif kind == "kill":
|
||||
for run in current:
|
||||
run.stop(0)
|
||||
elif kind == "app":
|
||||
threading.Thread(target=_tui_put_app, args=(effect[1], inbox),
|
||||
daemon=True).start()
|
||||
|
||||
def on_interrupt(_signum: int, _frame: object) -> None:
|
||||
flags["interrupt"] = True
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ esac
|
||||
TEMP_INSTALLER=""
|
||||
TEMP_DIRECTORY=""
|
||||
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
|
||||
DEFAULT_INSTALL_SHA256="be9d37048e800072d6d5d2a40afaa5a46d5b062577f83c0fa633fc15159de66a"
|
||||
DEFAULT_INSTALL_SHA256="cec348d9aa6c9f940a2c9dc9bc6414a923acea8137e4a3624d4710c9d88c38ad"
|
||||
MAX_INSTALLER_BYTES=2097152
|
||||
|
||||
cleanup() {
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"launchers": {
|
||||
"knulli": {
|
||||
"files": [
|
||||
{
|
||||
"dest": "roms/ports/RetroBIOS.sh",
|
||||
"repo_path": "ports/batocera/RetroBIOS.sh",
|
||||
"size": 1583,
|
||||
"sha256": "7b30f2f5d1c677dc160290562c24779b8e6d79e6db735e343beed7f377f2bbcf",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
"dest": "roms/ports/RetroBIOS.sh.keys",
|
||||
"repo_path": "ports/batocera/RetroBIOS.sh.keys",
|
||||
"size": 1157,
|
||||
"sha256": "82014d185bbefb3358e580ae28b77479d0ba60ee9a4158c719e3e46071eea8a9",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
"dest": "roms/ports/retrobios/README.md",
|
||||
"repo_path": "ports/batocera/README.md",
|
||||
"size": 1276,
|
||||
"sha256": "b45f6268d6e3e4e0f57337877ff707670325a8b5f8fa77c56668fb30a474ce34",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
"dest": "roms/ports/retrobios/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 611006,
|
||||
"sha256": "cec348d9aa6c9f940a2c9dc9bc6414a923acea8137e4a3624d4710c9d88c38ad",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
"dest": "roms/ports/retrobios/licenses/LICENSE-retrobios.txt",
|
||||
"repo_path": "LICENSE",
|
||||
"size": 1586,
|
||||
"sha256": "4caf349722255530d2aa5ccb98447a8af89454ed5a1f8499faa674de969878f0",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
"dest": "roms/ports/retrobios/licenses/NOTICE-retrobios.txt",
|
||||
"repo_path": "NOTICE",
|
||||
"size": 1555,
|
||||
"sha256": "5df6f621cba97bf5ffde17926373c6384a64041d8e820540226f5eb6d6758f19",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
"dest": "roms/ports/retrobios/packs.txt",
|
||||
"content": "batocera\tBatocera\nemudeck\tEmuDeck\nmisterfpga\tMiSTer_FPGA\nretroarch\tRetroArch\nretrobat\tRetroBat\nromm\tRomM\n",
|
||||
"size": 105,
|
||||
"sha256": "d9d48576bbfeb4821816a78310019eb4f9913ac45874c29a0118525f2481c8f9",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
"dest": "roms/ports/retrobios/rbevents.py",
|
||||
"repo_path": "ports/shared/rbevents.py",
|
||||
"size": 7258,
|
||||
"sha256": "e3c758e73bc2afb2d94714b2a3c9bd20fe0e75af32f2d9ea5634ba5b83a4205b",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
"dest": "roms/ports/retrobios/tui.txt",
|
||||
"repo_path": "ports/shared/tui.txt",
|
||||
"size": 34998,
|
||||
"sha256": "6e22292fc3f0688e0509bebd55f242fb3eecf9e149d5dc365ea75447e921bf78",
|
||||
"executable": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"muos": {
|
||||
"archive": {
|
||||
"dest": "ARCHIVE/RetroBIOS.muxapp",
|
||||
"size": 661660,
|
||||
"sha256": "681ae3d8eb8572640b57dd4ca8699cb2cb8f011d1071ba5ef286c1b2eeb1877d"
|
||||
},
|
||||
"members": [
|
||||
{
|
||||
"name": "RetroBIOS/README.md",
|
||||
"repo_path": "ports/muos/README.md",
|
||||
"size": 1127,
|
||||
"sha256": "c0cfafad5fae8e6764b263b14a06195b267f66c4fe042333941ae6bcc6dab265",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
"name": "RetroBIOS/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 611006,
|
||||
"sha256": "cec348d9aa6c9f940a2c9dc9bc6414a923acea8137e4a3624d4710c9d88c38ad",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
"name": "RetroBIOS/licenses/LICENSE-retrobios.txt",
|
||||
"repo_path": "LICENSE",
|
||||
"size": 1586,
|
||||
"sha256": "4caf349722255530d2aa5ccb98447a8af89454ed5a1f8499faa674de969878f0",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
"name": "RetroBIOS/licenses/NOTICE-retrobios.txt",
|
||||
"repo_path": "NOTICE",
|
||||
"size": 1555,
|
||||
"sha256": "5df6f621cba97bf5ffde17926373c6384a64041d8e820540226f5eb6d6758f19",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
"name": "RetroBIOS/mux_launch.sh",
|
||||
"repo_path": "ports/muos/mux_launch.sh",
|
||||
"size": 2216,
|
||||
"sha256": "8566922b2ba50e49314f3dab9e3327ff736dd718bb307bb19b39e46e2e8c0f36",
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "RetroBIOS/packs.txt",
|
||||
"content": "batocera\tBatocera\nemudeck\tEmuDeck\nmisterfpga\tMiSTer_FPGA\nretroarch\tRetroArch\nretrobat\tRetroBat\nromm\tRomM\n",
|
||||
"size": 105,
|
||||
"sha256": "d9d48576bbfeb4821816a78310019eb4f9913ac45874c29a0118525f2481c8f9",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
"name": "RetroBIOS/rbevents.py",
|
||||
"repo_path": "ports/shared/rbevents.py",
|
||||
"size": 7258,
|
||||
"sha256": "e3c758e73bc2afb2d94714b2a3c9bd20fe0e75af32f2d9ea5634ba5b83a4205b",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
"name": "RetroBIOS/retrobios.gptk",
|
||||
"repo_path": "ports/shared/retrobios.gptk",
|
||||
"size": 659,
|
||||
"sha256": "fdb30597ec757ff769add5052476ff51e3bc3910022a73ab2a91670a1b65c65a",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
"name": "RetroBIOS/tui.txt",
|
||||
"repo_path": "ports/shared/tui.txt",
|
||||
"size": 34998,
|
||||
"sha256": "6e22292fc3f0688e0509bebd55f242fb3eecf9e149d5dc365ea75447e921bf78",
|
||||
"executable": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"rocknix": {
|
||||
"files": [
|
||||
{
|
||||
"dest": ".config/modules/RetroBIOS.sh",
|
||||
"repo_path": "ports/tools/RetroBIOS.sh",
|
||||
"size": 4732,
|
||||
"sha256": "556c83159111a2071097a38590e3335aea90f76201473e064ba8365882fb2a6f",
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"dest": ".config/modules/retrobios/README.md",
|
||||
"repo_path": "ports/tools/README.md",
|
||||
"size": 949,
|
||||
"sha256": "a5dfc71ff2a932a6c102558c0ac49790b112e96914bf29f396d3f90af75d0997",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
"dest": ".config/modules/retrobios/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 611006,
|
||||
"sha256": "cec348d9aa6c9f940a2c9dc9bc6414a923acea8137e4a3624d4710c9d88c38ad",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
"dest": ".config/modules/retrobios/licenses/LICENSE-retrobios.txt",
|
||||
"repo_path": "LICENSE",
|
||||
"size": 1586,
|
||||
"sha256": "4caf349722255530d2aa5ccb98447a8af89454ed5a1f8499faa674de969878f0",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
"dest": ".config/modules/retrobios/licenses/NOTICE-retrobios.txt",
|
||||
"repo_path": "NOTICE",
|
||||
"size": 1555,
|
||||
"sha256": "5df6f621cba97bf5ffde17926373c6384a64041d8e820540226f5eb6d6758f19",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
"dest": ".config/modules/retrobios/packs.txt",
|
||||
"content": "batocera\tBatocera\nemudeck\tEmuDeck\nmisterfpga\tMiSTer_FPGA\nretroarch\tRetroArch\nretrobat\tRetroBat\nromm\tRomM\n",
|
||||
"size": 105,
|
||||
"sha256": "d9d48576bbfeb4821816a78310019eb4f9913ac45874c29a0118525f2481c8f9",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
"dest": ".config/modules/retrobios/rbevents.py",
|
||||
"repo_path": "ports/shared/rbevents.py",
|
||||
"size": 7258,
|
||||
"sha256": "e3c758e73bc2afb2d94714b2a3c9bd20fe0e75af32f2d9ea5634ba5b83a4205b",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
"dest": ".config/modules/retrobios/retrobios.gptk",
|
||||
"repo_path": "ports/shared/retrobios.gptk",
|
||||
"size": 659,
|
||||
"sha256": "fdb30597ec757ff769add5052476ff51e3bc3910022a73ab2a91670a1b65c65a",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
"dest": ".config/modules/retrobios/tui.txt",
|
||||
"repo_path": "ports/shared/tui.txt",
|
||||
"size": 34998,
|
||||
"sha256": "6e22292fc3f0688e0509bebd55f242fb3eecf9e149d5dc365ea75447e921bf78",
|
||||
"executable": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@
|
||||
"transfer",
|
||||
"downloaded",
|
||||
"summary",
|
||||
"app",
|
||||
"end"
|
||||
]
|
||||
}
|
||||
@@ -87,6 +88,9 @@
|
||||
{
|
||||
"$ref": "#/$defs/summary"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/app"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/end"
|
||||
}
|
||||
@@ -740,6 +744,55 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"event",
|
||||
"system",
|
||||
"folder",
|
||||
"ok",
|
||||
"reason",
|
||||
"dests",
|
||||
"written",
|
||||
"kept"
|
||||
],
|
||||
"properties": {
|
||||
"event": {
|
||||
"const": "app"
|
||||
},
|
||||
"system": {
|
||||
"type": "string"
|
||||
},
|
||||
"folder": {
|
||||
"type": "string"
|
||||
},
|
||||
"ok": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"dests": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"written": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"kept": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"end": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -45,7 +45,7 @@ KEY_TYPES: dict[str, dict] = {
|
||||
"pending", "code",
|
||||
), _COUNT),
|
||||
**dict.fromkeys(("truncated", "ok", "interrupted"), _FLAG),
|
||||
**dict.fromkeys(("systems", "other_drives", "dests"), _NAMES),
|
||||
**dict.fromkeys(("systems", "other_drives", "dests", "written"), _NAMES),
|
||||
**dict.fromkeys(("platforms", "failed", "skipped", "left_out", "kept"), _RECORDS),
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Write install/launchers/index.json: the handheld launchers as files for a card.
|
||||
|
||||
python scripts/generate_launcher_index.py rewrite the list
|
||||
python scripts/generate_launcher_index.py --check exit 1 when it is stale
|
||||
|
||||
install.py --add-app reads this list from the ref its file lists come from,
|
||||
and puts on a handheld's card the launcher of its system, each file checked
|
||||
against the size and SHA-256 recorded here. The files are the members of the
|
||||
archives scripts/build_port.py builds, from the same sources: muOS gets its
|
||||
application archive, rebuilt by the installer from the members and checked
|
||||
against the hash recorded for it, the others the archive's files at their
|
||||
place on the card. Where each system's files go is install.py's APP_PLACES.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from build_port import (
|
||||
BATOCERA_ZIP,
|
||||
MUOS_APP,
|
||||
REPO_ROOT,
|
||||
TOOLS_ZIP,
|
||||
_packs_table,
|
||||
archive_members,
|
||||
pinned_installer_sha256,
|
||||
)
|
||||
|
||||
OUTPUT = REPO_ROOT / "install" / "launchers" / "index.json"
|
||||
|
||||
_spec = importlib.util.spec_from_file_location("install", REPO_ROOT / "install.py")
|
||||
_install = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_install)
|
||||
|
||||
# The archive whose members each system gets, by its key in APP_PLACES.
|
||||
LAUNCHER_ARCHIVES = {"muos": MUOS_APP, "knulli": BATOCERA_ZIP, "rocknix": TOOLS_ZIP}
|
||||
|
||||
|
||||
def _entry(field: str, name: str, source: Path | None, root: Path) -> dict:
|
||||
"""A member as the list records it: where it comes from, its size and
|
||||
hash, and whether it is executable in the repository."""
|
||||
if source is None:
|
||||
data = _packs_table(root)
|
||||
origin: dict = {"content": data.decode("utf-8")}
|
||||
executable = False
|
||||
else:
|
||||
data = source.read_bytes()
|
||||
origin = {"repo_path": source.relative_to(root).as_posix()}
|
||||
executable = bool(source.stat().st_mode & 0o111)
|
||||
return {field: name, **origin, "size": len(data),
|
||||
"sha256": hashlib.sha256(data).hexdigest(), "executable": executable}
|
||||
|
||||
|
||||
def _member_bytes(entry: dict, root: Path) -> bytes:
|
||||
if "content" in entry:
|
||||
return entry["content"].encode("utf-8")
|
||||
return (root / entry["repo_path"]).read_bytes()
|
||||
|
||||
|
||||
def build_index(root: Path = REPO_ROOT) -> dict:
|
||||
"""Every launcher a card can carry, from the archives' members."""
|
||||
actual = hashlib.sha256((root / "install.py").read_bytes()).hexdigest()
|
||||
if actual != pinned_installer_sha256(root):
|
||||
raise ValueError("install.py is not the installer install.sh pins: re-pin the "
|
||||
"bootstraps before writing the launcher list")
|
||||
places = {place.key: place for place in _install.APP_PLACES.values()}
|
||||
if set(places) != set(LAUNCHER_ARCHIVES):
|
||||
raise ValueError(f"APP_PLACES names {sorted(places)}, the archives "
|
||||
f"{sorted(LAUNCHER_ARCHIVES)}")
|
||||
launchers: dict[str, dict] = {}
|
||||
for key, archive in sorted(LAUNCHER_ARCHIVES.items()):
|
||||
folder = places[key].folder
|
||||
members = archive_members(archive, root)
|
||||
if archive == MUOS_APP:
|
||||
entries = [_entry("name", name, source, root) for name, source in members]
|
||||
data = _install.app_archive([(entry, _member_bytes(entry, root))
|
||||
for entry in entries])
|
||||
launchers[key] = {
|
||||
"archive": {"dest": f"{folder}/{archive}", "size": len(data),
|
||||
"sha256": hashlib.sha256(data).hexdigest()},
|
||||
"members": entries,
|
||||
}
|
||||
else:
|
||||
# Executable only where the menu starts the script as a program:
|
||||
# a mode a FAT card cannot hold is refused by its driver.
|
||||
files = [_entry("dest", f"{folder}/{name}", source, root)
|
||||
for name, source in members]
|
||||
for entry in files:
|
||||
entry["executable"] = entry["executable"] and places[key].executable
|
||||
launchers[key] = {"files": files}
|
||||
return {"schema": _install.APP_SCHEMA, "launchers": launchers}
|
||||
|
||||
|
||||
def render(index: dict) -> str:
|
||||
return json.dumps(index, indent=2, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(allow_abbrev=False, description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--check", action="store_true",
|
||||
help="exit 1 when the list differs from the launchers, writing nothing")
|
||||
parser.add_argument("--output", type=Path, default=OUTPUT, help=argparse.SUPPRESS)
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
text = render(build_index())
|
||||
except (OSError, ValueError) as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
current = args.output.read_text(encoding="utf-8") if args.output.is_file() else None
|
||||
if current == text:
|
||||
return 0
|
||||
if args.check:
|
||||
print(f"{args.output} differs from the launchers in ports/: run "
|
||||
"python scripts/generate_launcher_index.py", file=sys.stderr)
|
||||
return 1
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(text, encoding="utf-8")
|
||||
print(f"Wrote {args.output}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from common import run_cli
|
||||
|
||||
run_cli(main)
|
||||
@@ -0,0 +1,470 @@
|
||||
"""The RetroBIOS app put on a handheld's card from a computer (--add-app).
|
||||
|
||||
Cards are folders holding the files their systems write, the repository a
|
||||
loopback copy of the launcher list and the files it names. What a console
|
||||
then does with the files (the Archive Manager, the Ports list, the Tools
|
||||
menu) is not exercised.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
sys.path.insert(0, str(REPO_ROOT / "tests"))
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import install # noqa: E402
|
||||
from test_install_selection import FakeRepository # noqa: E402
|
||||
|
||||
INDEX = REPO_ROOT / "install" / "launchers" / "index.json"
|
||||
|
||||
|
||||
def make_card(root: Path, system: str) -> Path:
|
||||
"""A card as each system leaves it, and the BIOS folder on it."""
|
||||
bios = _card_files(root, system)
|
||||
bios.mkdir(parents=True, exist_ok=True)
|
||||
return bios
|
||||
|
||||
|
||||
def _card_files(root: Path, system: str) -> Path:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
if system == "muOS":
|
||||
(root / "MUOS" / "bios").mkdir(parents=True)
|
||||
return root / "MUOS" / "bios"
|
||||
if system == "KNULLI":
|
||||
(root / "system").mkdir()
|
||||
(root / "system" / "batocera.conf").write_text("system.hostname=KNULLI\n")
|
||||
return root / "bios"
|
||||
if system == "ROCKNIX":
|
||||
(root / ".config" / "system" / "configs").mkdir(parents=True)
|
||||
(root / ".config" / "system" / "configs" / "system.cfg").write_text(
|
||||
"root.password=rocknix\n")
|
||||
return root / "roms" / "bios"
|
||||
if system == "Onion":
|
||||
(root / ".tmp_update" / "onionVersion").mkdir(parents=True)
|
||||
(root / ".tmp_update" / "onionVersion" / "version.txt").write_text("4.3.1\n")
|
||||
return root / "BIOS"
|
||||
if system == "spruceOS":
|
||||
(root / "spruce").mkdir()
|
||||
return root / "BIOS"
|
||||
if system == "MinUI":
|
||||
(root / ".system").mkdir()
|
||||
(root / ".system" / "version.txt").write_text("MinUI-20250101\n")
|
||||
return root
|
||||
raise ValueError(system)
|
||||
|
||||
|
||||
# The file list each card's system gets.
|
||||
CARD_PLATFORMS = {"muOS": "retroarch", "KNULLI": "knulli", "ROCKNIX": "rocknix",
|
||||
"Onion": "retroarch", "spruceOS": "retroarch", "MinUI": "minui"}
|
||||
|
||||
|
||||
class AppRepository(FakeRepository):
|
||||
"""The fake repository with the launcher list and every file it names,
|
||||
and the BIOS lists of the systems whose cards are tried."""
|
||||
|
||||
def __enter__(self) -> AppRepository:
|
||||
super().__enter__()
|
||||
index = json.loads(INDEX.read_text(encoding="utf-8"))
|
||||
(self.root / "install" / "launchers").mkdir()
|
||||
(self.root / "install" / "launchers" / "index.json").write_text(json.dumps(index))
|
||||
for launcher in index["launchers"].values():
|
||||
for entry in launcher.get("files", launcher.get("members", [])):
|
||||
if "repo_path" in entry:
|
||||
target = self.root / entry["repo_path"]
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(REPO_ROOT / entry["repo_path"], target)
|
||||
base = json.loads((self.root / "install" / "retroarch.json").read_text())
|
||||
for plat in ("knulli", "rocknix", "minui"):
|
||||
(self.root / "install" / f"{plat}.json").write_text(
|
||||
json.dumps({**base, "platform": plat}))
|
||||
return self
|
||||
|
||||
def card(self, system: str) -> tuple[Path, Path]:
|
||||
root = self.root / "cards" / system
|
||||
return root, make_card(root, system)
|
||||
|
||||
def tamper(self, repo_path: str) -> None:
|
||||
"""One byte changed, the size kept: only the hash tells."""
|
||||
path = self.root / repo_path
|
||||
data = bytearray(path.read_bytes())
|
||||
data[-1] ^= 0x20
|
||||
path.write_bytes(bytes(data))
|
||||
|
||||
def cli(self, *options: str, env: dict | None = None) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(REPO_ROOT / "install.py"), *options],
|
||||
env={**self.env(), **(env or {})}, capture_output=True, text=True,
|
||||
timeout=120, check=False, stdin=subprocess.DEVNULL)
|
||||
|
||||
|
||||
def _tempdir(test: unittest.TestCase) -> Path:
|
||||
(REPO_ROOT / "tmp").mkdir(exist_ok=True)
|
||||
path = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
|
||||
test.addCleanup(shutil.rmtree, path, True)
|
||||
return path
|
||||
|
||||
|
||||
class Placement(unittest.TestCase):
|
||||
def test_each_card_and_where_its_app_goes(self):
|
||||
root = _tempdir(self)
|
||||
expected = {"muOS": "ARCHIVE", "KNULLI": "roms/ports", "ROCKNIX": ".config/modules"}
|
||||
for system, folder in expected.items():
|
||||
with self.subTest(system=system):
|
||||
card = install.card_app(make_card(root / system, system))
|
||||
self.assertIsNotNone(card)
|
||||
self.assertEqual(card.system, system)
|
||||
self.assertEqual(card.root, root / system)
|
||||
self.assertEqual(card.place.folder, folder)
|
||||
self.assertEqual(card.why, "")
|
||||
|
||||
def test_a_bios_folder_inside_the_card_finds_its_root(self):
|
||||
root = _tempdir(self)
|
||||
bios = make_card(root / "sd1", "muOS")
|
||||
bios.mkdir(parents=True, exist_ok=True)
|
||||
self.assertEqual(install.card_app(bios).root, root / "sd1")
|
||||
# The card's root itself is not its BIOS folder.
|
||||
self.assertIsNone(install.card_app(root / "sd1"))
|
||||
|
||||
def test_cards_without_a_launcher_say_why(self):
|
||||
root = _tempdir(self)
|
||||
for system, words in (("Onion", "has no Python"), ("MinUI", "has no Python"),
|
||||
("spruceOS", "no RetroBIOS app for spruceOS")):
|
||||
with self.subTest(system=system):
|
||||
card = install.card_app(make_card(root / system, system))
|
||||
self.assertIsNone(card.place)
|
||||
self.assertIn(words, card.why)
|
||||
|
||||
def test_a_folder_on_no_card(self):
|
||||
self.assertIsNone(install.card_app(_tempdir(self) / "bios"))
|
||||
|
||||
def test_the_table_names_systems_detection_names(self):
|
||||
names = {rule.name for rule in install._card_rules()}
|
||||
names |= {install.display_name(name) for name, _f, _p in install._CARD_RULES}
|
||||
self.assertLessEqual(set(install.APP_PLACES), names)
|
||||
self.assertLessEqual(install.NO_PYTHON_CARDS, names)
|
||||
self.assertFalse(set(install.APP_PLACES) & install.NO_PYTHON_CARDS)
|
||||
|
||||
def test_the_list_follows_the_table(self):
|
||||
index = json.loads(INDEX.read_text(encoding="utf-8"))
|
||||
for place in install.APP_PLACES.values():
|
||||
with self.subTest(key=place.key):
|
||||
targets = install.app_targets(index, place)
|
||||
for target in targets:
|
||||
self.assertTrue(target["dest"].startswith(place.folder + "/"))
|
||||
self.assertEqual(target.get("executable", False) and not place.executable,
|
||||
False, target["dest"])
|
||||
|
||||
|
||||
class TheList(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.index = json.loads(INDEX.read_text(encoding="utf-8"))
|
||||
self.place = install.APP_PLACES["KNULLI"]
|
||||
|
||||
def refused(self, change) -> str:
|
||||
data = json.loads(json.dumps(self.index))
|
||||
change(data["launchers"]["knulli"]["files"][0], data)
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
install.app_targets(data, self.place)
|
||||
return str(caught.exception)
|
||||
|
||||
def test_entries_are_held_to_their_folder(self):
|
||||
self.assertIn("outside", self.refused(
|
||||
lambda entry, _d: entry.update(dest="roms/RetroBIOS.sh")))
|
||||
self.assertIn("unsafe", self.refused(
|
||||
lambda entry, _d: entry.update(dest="roms/ports/../../bios/x")))
|
||||
self.assertIn("unsafe", self.refused(
|
||||
lambda entry, _d: entry.update(repo_path="/etc/passwd")))
|
||||
|
||||
def test_hash_size_and_source_are_checked(self):
|
||||
self.assertIn("sha256", self.refused(lambda entry, _d: entry.update(sha256="ab")))
|
||||
self.assertIn("size", self.refused(lambda entry, _d: entry.update(size=0)))
|
||||
self.assertIn("repo_path or content", self.refused(
|
||||
lambda entry, _d: entry.update(content="x")))
|
||||
self.assertIn("schema", self.refused(lambda _e, data: data.update(schema=2)))
|
||||
self.assertIn("no launcher", self.refused(
|
||||
lambda _e, data: data["launchers"].pop("knulli")))
|
||||
|
||||
def test_the_archive_rebuilds_to_its_recorded_hash(self):
|
||||
launcher = self.index["launchers"]["muos"]
|
||||
members = [(entry, (REPO_ROOT / entry["repo_path"]).read_bytes()
|
||||
if "repo_path" in entry else entry["content"].encode())
|
||||
for entry in launcher["members"]]
|
||||
data = install.app_archive(list(reversed(members)))
|
||||
# Under every Python the installer runs on: CI runs this under 3.8 too.
|
||||
self.assertEqual(hashlib.sha256(data).hexdigest(), launcher["archive"]["sha256"])
|
||||
self.assertEqual(len(data), launcher["archive"]["size"])
|
||||
|
||||
@unittest.skipIf(sys.version_info < (3, 9), "the build scripts need Python 3.9")
|
||||
def test_the_archive_holds_what_the_release_archive_holds(self):
|
||||
launcher = self.index["launchers"]["muos"]
|
||||
members = [(entry, (REPO_ROOT / entry["repo_path"]).read_bytes()
|
||||
if "repo_path" in entry else entry["content"].encode())
|
||||
for entry in launcher["members"]]
|
||||
data = install.app_archive(members)
|
||||
import build_port # noqa: PLC0415
|
||||
built = _tempdir(self) / build_port.MUOS_APP
|
||||
build_port.build_archive(build_port.MUOS_APP, built)
|
||||
with zipfile.ZipFile(built) as release, zipfile.ZipFile(io.BytesIO(data)) as ours:
|
||||
self.assertEqual(release.namelist(), ours.namelist())
|
||||
for name in release.namelist():
|
||||
self.assertEqual(release.read(name), ours.read(name), name)
|
||||
self.assertEqual(release.getinfo(name).external_attr,
|
||||
ours.getinfo(name).external_attr, name)
|
||||
|
||||
|
||||
class PutTheApp(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.repo = AppRepository()
|
||||
self.repo.__enter__()
|
||||
self.addCleanup(self.repo.__exit__, None, None, None)
|
||||
install.KEPT.reset()
|
||||
patcher = mock.patch.multiple(
|
||||
install, LAUNCHERS_URL=f"{self.repo.env()['RETROBIOS_BASE_URL']}"
|
||||
"/install/launchers/index.json",
|
||||
RAW_FILE_URL=f"{self.repo.env()['RETROBIOS_BASE_URL']}/{{path}}")
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
self.index = json.loads(INDEX.read_text(encoding="utf-8"))
|
||||
self.bios: dict[str, Path] = {}
|
||||
|
||||
def put(self, system: str) -> tuple[install.CardApp, dict]:
|
||||
if system not in self.bios:
|
||||
self.bios[system] = self.repo.card(system)[1]
|
||||
card = install.card_app(self.bios[system])
|
||||
return card, install.put_app(card)
|
||||
|
||||
def assertMatchesTheList(self, card: install.CardApp) -> None:
|
||||
for target in install.app_targets(self.index, card.place):
|
||||
path = card.root / target["dest"]
|
||||
self.assertEqual(hashlib.sha256(path.read_bytes()).hexdigest(), target["sha256"])
|
||||
|
||||
def test_muos_gets_its_archive(self):
|
||||
card, fields = self.put("muOS")
|
||||
self.assertTrue(fields["ok"], fields)
|
||||
self.assertEqual(fields["written"], ["ARCHIVE/RetroBIOS.muxapp"])
|
||||
self.assertMatchesTheList(card)
|
||||
with zipfile.ZipFile(card.root / "ARCHIVE" / "RetroBIOS.muxapp") as archive:
|
||||
self.assertEqual(archive.read("RetroBIOS/install.py"),
|
||||
(REPO_ROOT / "install.py").read_bytes())
|
||||
mode = archive.getinfo("RetroBIOS/mux_launch.sh").external_attr >> 16
|
||||
self.assertEqual(mode & 0o777, 0o755)
|
||||
|
||||
def test_knulli_and_rocknix_get_the_files(self):
|
||||
for system, script, executable in (("KNULLI", "roms/ports/RetroBIOS.sh", False),
|
||||
("ROCKNIX", ".config/modules/RetroBIOS.sh", True)):
|
||||
with self.subTest(system=system):
|
||||
card, fields = self.put(system)
|
||||
self.assertTrue(fields["ok"], fields)
|
||||
self.assertEqual(sorted(fields["written"]), sorted(fields["dests"]))
|
||||
self.assertMatchesTheList(card)
|
||||
mode = (card.root / script).stat().st_mode
|
||||
self.assertEqual(bool(mode & stat.S_IXUSR), executable)
|
||||
|
||||
def test_a_second_run_writes_nothing(self):
|
||||
card, first = self.put("KNULLI")
|
||||
stamps = {path: path.stat().st_mtime_ns for path in card.root.rglob("*")}
|
||||
_card, second = self.put("KNULLI")
|
||||
self.assertTrue(second["ok"])
|
||||
self.assertEqual(second["written"], [])
|
||||
self.assertEqual(second["dests"], first["dests"])
|
||||
self.assertEqual({path: path.stat().st_mtime_ns for path in card.root.rglob("*")},
|
||||
stamps)
|
||||
self.assertIn("already on the KNULLI card", install.app_lines(card, second)[0])
|
||||
|
||||
def test_a_different_file_in_place_is_kept_aside(self):
|
||||
root, bios = self.repo.card("KNULLI")
|
||||
self.bios["KNULLI"] = bios
|
||||
mine = root / "roms" / "ports" / "RetroBIOS.sh"
|
||||
mine.parent.mkdir(parents=True)
|
||||
mine.write_text("my own script\n")
|
||||
card, fields = self.put("KNULLI")
|
||||
self.assertTrue(fields["ok"], fields)
|
||||
self.assertEqual(len(fields["kept"]), 1)
|
||||
self.assertEqual(fields["kept"][0]["files"], 1)
|
||||
kept = list(Path(fields["kept"][0]["folder"]).rglob("RetroBIOS.sh"))
|
||||
self.assertEqual([path.read_text() for path in kept], ["my own script\n"])
|
||||
self.assertMatchesTheList(card)
|
||||
self.assertTrue(any("kept in" in line for line in install.app_lines(card, fields)))
|
||||
|
||||
def test_a_tampered_file_is_refused_and_nothing_written(self):
|
||||
self.repo.tamper("ports/shared/tui.txt")
|
||||
root, bios = self.repo.card("ROCKNIX")
|
||||
self.bios["ROCKNIX"] = bios
|
||||
before = sorted(root.rglob("*"))
|
||||
card, fields = self.put("ROCKNIX")
|
||||
self.assertFalse(fields["ok"])
|
||||
self.assertIn("SHA-256 mismatch", fields["reason"])
|
||||
self.assertEqual(fields["written"], [])
|
||||
self.assertEqual(sorted(root.rglob("*")), before)
|
||||
self.assertIn("could not be put", install.app_lines(card, fields)[0])
|
||||
|
||||
def test_a_tampered_member_never_reaches_the_archive(self):
|
||||
self.repo.tamper("ports/muos/mux_launch.sh")
|
||||
root, bios = self.repo.card("muOS")
|
||||
self.bios["muOS"] = bios
|
||||
_card, fields = self.put("muOS")
|
||||
self.assertFalse(fields["ok"])
|
||||
self.assertFalse((root / "ARCHIVE").exists())
|
||||
|
||||
def test_a_card_without_a_launcher_is_not_written(self):
|
||||
root, bios = self.repo.card("Onion")
|
||||
self.bios["Onion"] = bios
|
||||
_card, fields = self.put("Onion")
|
||||
self.assertFalse(fields["ok"])
|
||||
self.assertIn("has no Python", fields["reason"])
|
||||
self.assertEqual(sorted(p.name for p in root.iterdir()), [".tmp_update", "BIOS"])
|
||||
|
||||
|
||||
class TheQuestion(unittest.TestCase):
|
||||
"""Line mode at a terminal asks once, and only yes puts the app."""
|
||||
|
||||
def run_finish(self, answer: str, system: str = "muOS", **given) -> list:
|
||||
root = _tempdir(self)
|
||||
bios = make_card(root / "card", system)
|
||||
args = mock.Mock(add_app=False, yes=False)
|
||||
for key, value in given.items():
|
||||
setattr(args, key, value)
|
||||
put = mock.Mock(return_value={"system": system, "folder": str(root / "card"),
|
||||
"ok": True, "reason": "", "dests": ["x"],
|
||||
"written": ["x"], "kept": []})
|
||||
out = io.StringIO()
|
||||
with mock.patch.object(install, "_ask", return_value=answer) as ask, \
|
||||
mock.patch.object(install, "put_app", put), \
|
||||
mock.patch("sys.stdout", out):
|
||||
install.finish_app(args, [("retroarch", bios)], [], interactive=True)
|
||||
self.output = out.getvalue()
|
||||
return [ask.call_args_list, put.call_args_list]
|
||||
|
||||
def test_yes_puts_it(self):
|
||||
asked, put = self.run_finish("y")
|
||||
self.assertEqual(len(asked), 1)
|
||||
self.assertIn("[y/N]", asked[0][0][0])
|
||||
self.assertEqual(len(put), 1)
|
||||
self.assertIn("Archive Manager", self.output)
|
||||
|
||||
def test_enter_or_no_leaves_the_card_alone(self):
|
||||
for answer in ("", "n", "no", "q"):
|
||||
with self.subTest(answer=answer):
|
||||
_asked, put = self.run_finish(answer)
|
||||
self.assertEqual(put, [])
|
||||
|
||||
def test_yes_flag_asks_nothing(self):
|
||||
asked, put = self.run_finish("y", yes=True)
|
||||
self.assertEqual((asked, put), ([], []))
|
||||
|
||||
def test_a_console_without_python_says_so_once(self):
|
||||
asked, put = self.run_finish("y", system="Onion")
|
||||
self.assertEqual((asked, put), ([], []))
|
||||
self.assertIn(install.NO_PYTHON_WORDS, self.output)
|
||||
|
||||
|
||||
class TheOption(unittest.TestCase):
|
||||
"""--add-app is applied or refused, never ignored."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.repo = AppRepository()
|
||||
cls.repo.__enter__()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.repo.__exit__(None, None, None)
|
||||
|
||||
def test_refused_where_it_cannot_apply(self):
|
||||
root = _tempdir(self)
|
||||
pack = root / "RetroArch_BIOS_Pack.zip"
|
||||
zipfile.ZipFile(pack, "w").close()
|
||||
for options, words in (
|
||||
(["--check"], "--check and --dry-run do not"),
|
||||
(["--dry-run"], "--check and --dry-run do not"),
|
||||
(["--list-systems"], "a listing does not"),
|
||||
(["--from-pack", str(pack)], "--from-pack makes no network request"),
|
||||
(["--offline"], "which --offline does not"),
|
||||
(["--restore", str(root)], "takes no --add-app"),
|
||||
):
|
||||
with self.subTest(options=options):
|
||||
where = [] if "--restore" in options else ["--platform", "retroarch",
|
||||
"--dest", str(root)]
|
||||
proc = self.repo.cli(*where, "--add-app", *options)
|
||||
self.assertEqual(proc.returncode, 2, proc.stderr)
|
||||
self.assertIn("error:", proc.stderr)
|
||||
self.assertIn(words, proc.stderr)
|
||||
|
||||
def test_refused_for_a_folder_on_no_card_or_no_launcher(self):
|
||||
root = _tempdir(self)
|
||||
for system, words in ((None, "is not on a card this installer recognises"),
|
||||
("Onion", "Onion has no Python"),
|
||||
("spruceOS", "no RetroBIOS app for spruceOS yet")):
|
||||
with self.subTest(system=system):
|
||||
bios = make_card(root / str(system), system) if system else root / "bios"
|
||||
proc = self.repo.cli("--platform", "retroarch", "--dest", str(bios),
|
||||
"--add-app", "--events")
|
||||
self.assertEqual(proc.returncode, 2, proc.stderr)
|
||||
self.assertIn(words, proc.stderr)
|
||||
events = [json.loads(line) for line in proc.stdout.splitlines()]
|
||||
app = [event for event in events if event["event"] == "app"]
|
||||
self.assertEqual(len(app), 1)
|
||||
self.assertFalse(app[0]["ok"])
|
||||
self.assertIn(words, app[0]["reason"])
|
||||
self.assertEqual(events[-1], {"event": "end", "code": 2})
|
||||
self.assertFalse([e for e in events if e["event"] == "download"])
|
||||
|
||||
def test_applied_after_the_install_and_reported(self):
|
||||
root = _tempdir(self)
|
||||
for system in ("muOS", "KNULLI"):
|
||||
with self.subTest(system=system):
|
||||
bios = make_card(root / system, system)
|
||||
plat = CARD_PLATFORMS[system]
|
||||
command = ("--platform", plat, "--dest", str(root / system), "--add-app",
|
||||
"--events")
|
||||
proc = self.repo.cli(*command)
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
events = [json.loads(line) for line in proc.stdout.splitlines()]
|
||||
kinds = [event["event"] for event in events]
|
||||
self.assertEqual(kinds[-3:], ["summary", "app", "end"])
|
||||
app = events[-2]
|
||||
self.assertTrue(app["ok"], app)
|
||||
self.assertEqual(app["system"], system)
|
||||
self.assertEqual(app["folder"], str(root / system))
|
||||
self.assertEqual(sorted(app["written"]), sorted(app["dests"]))
|
||||
self.assertEqual(list(app), ["event", *install.EVENT_KEYS["app"]])
|
||||
self.assertEqual(len(list(bios.iterdir())), 5)
|
||||
self.assertIn("RetroBIOS is on the", proc.stderr)
|
||||
again = self.repo.cli(*command)
|
||||
app = [json.loads(line) for line in again.stdout.splitlines()][-2]
|
||||
self.assertEqual((again.returncode, app["ok"], app["written"]), (0, True, []))
|
||||
|
||||
def test_not_added_when_the_install_does_not_finish(self):
|
||||
root = _tempdir(self)
|
||||
make_card(root / "card", "muOS")
|
||||
# A BIOS folder that is a file: every download fails.
|
||||
(root / "card" / "MUOS" / "bios").rmdir()
|
||||
(root / "card" / "MUOS" / "bios").write_text("in the way")
|
||||
proc = self.repo.cli("--platform", "retroarch", "--dest", str(root / "card"),
|
||||
"--add-app", "--events")
|
||||
self.assertNotEqual(proc.returncode, 0)
|
||||
events = [json.loads(line) for line in proc.stdout.splitlines()]
|
||||
app = [event for event in events if event["event"] == "app"]
|
||||
self.assertEqual(len(app), 1, proc.stderr)
|
||||
self.assertEqual((app[0]["ok"], app[0]["reason"]),
|
||||
(False, "the install did not finish"))
|
||||
self.assertFalse((root / "card" / "ARCHIVE").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+194
-1
@@ -79,7 +79,7 @@ def _args(**given) -> argparse.Namespace:
|
||||
cores_dir=None, system=None, core=None, region=None, no_game_data=False,
|
||||
list_systems=False, list_cores=False, romm_firmware=None, list_emulators=False,
|
||||
no_input=False, dry_run=False, yes=False, quiet=False, events=False,
|
||||
restore=None, from_pack=None, manifest=None, tui=False, plain=False,
|
||||
restore=None, from_pack=None, manifest=None, tui=False, plain=False, add_app=False,
|
||||
)
|
||||
values["from"] = None
|
||||
values.update(given)
|
||||
@@ -3380,6 +3380,144 @@ class SaveDirectoryRepository(FakeRepository):
|
||||
return self
|
||||
|
||||
|
||||
def _app_card(test: unittest.TestCase, system: str) -> tuple[Path, Path]:
|
||||
"""A card of system in the repository's tmp/, its root and BIOS folder."""
|
||||
from test_install_app import make_card # noqa: PLC0415
|
||||
|
||||
(REPO_ROOT / "tmp").mkdir(exist_ok=True)
|
||||
root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
|
||||
test.addCleanup(shutil.rmtree, root, True)
|
||||
return root / "card", make_card(root / "card", system)
|
||||
|
||||
|
||||
def _installed_state(dest: Path, code: int = 0, failed: list | None = None) -> install.TuiState:
|
||||
"""The result screen of an install into dest."""
|
||||
state = fresh_state()
|
||||
state.platform, state.dest, state.manifest = "retroarch", dest, MANIFEST
|
||||
state.stack, state.run, state.code = ["result"], "install", code
|
||||
state.summary = {"event": "summary", "mode": "install", "installed": 5, "up_to_date": 0,
|
||||
"pending": 0, "failed": failed or [], "skipped": [], "left_out": [],
|
||||
"kept": [], "interrupted": False}
|
||||
return state
|
||||
|
||||
|
||||
class AppOnTheCard(unittest.TestCase):
|
||||
"""The RetroBIOS app offered on the result screen and in the options."""
|
||||
|
||||
def setUp(self):
|
||||
patcher = mock.patch.dict(os.environ)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
os.environ.pop(install.OFFLINE_VARIABLE, None)
|
||||
|
||||
def fields(self, card_root: Path, ok: bool = True) -> dict:
|
||||
return {"event": "app", "system": "muOS", "folder": str(card_root), "ok": ok,
|
||||
"reason": "" if ok else "it could not be downloaded: no connection",
|
||||
"dests": ["ARCHIVE/RetroBIOS.muxapp"],
|
||||
"written": ["ARCHIVE/RetroBIOS.muxapp"] if ok else [], "kept": []}
|
||||
|
||||
def test_the_result_offers_it_and_exit_stays_the_default(self):
|
||||
root, bios = _app_card(self, "muOS")
|
||||
state = _installed_state(bios)
|
||||
rows = install.tui_rows(state)
|
||||
self.assertEqual([row.action for row in rows], ["exit", "addapp"])
|
||||
self.assertEqual(rows[1].label, install.APP_OFFER)
|
||||
screen = "\n".join(install.tui_view(state, 80, 24))
|
||||
self.assertIn(install.APP_OFFER, screen)
|
||||
self.assertIn("A/Enter Exit", screen)
|
||||
self.assertEqual(press(state, "enter"), [])
|
||||
self.assertEqual(state.exit_code, 0)
|
||||
|
||||
def test_choosing_it_puts_it_then_says_what_it_did(self):
|
||||
root, bios = _app_card(self, "muOS")
|
||||
state = _installed_state(bios)
|
||||
effects = press(state, "down", "enter")
|
||||
self.assertEqual(effects, [("app", install.card_app(bios))])
|
||||
self.assertTrue(state.app_running)
|
||||
screen = "\n".join(install.tui_view(state, 80, 24))
|
||||
self.assertIn("Putting RetroBIOS on the card...", screen)
|
||||
# Nothing ends the screen while the card is written.
|
||||
self.assertEqual(press(state, "enter", "esc"), [])
|
||||
self.assertIsNone(state.exit_code)
|
||||
install.tui_receive(state, ("app", self.fields(root)))
|
||||
self.assertEqual(install.tui_rows(state), [])
|
||||
screen = "\n".join(install.tui_view(state, 80, 24))
|
||||
self.assertIn("RetroBIOS is on the muOS card: 1 file written", screen)
|
||||
self.assertIn("Archive Manager", screen)
|
||||
self.assertIn("--add-app", install.tui_command(state))
|
||||
press(state, "enter")
|
||||
self.assertEqual(state.exit_code, 0)
|
||||
|
||||
def test_a_failure_is_said_and_ends_on_1(self):
|
||||
root, bios = _app_card(self, "KNULLI")
|
||||
state = _installed_state(bios)
|
||||
press(state, "down", "enter")
|
||||
install.tui_receive(state, ("app", {**self.fields(root, ok=False), "system": "KNULLI"}))
|
||||
_title, lines = install.tui_outcome(state)
|
||||
self.assertTrue(any("could not be put on the KNULLI card" in line for line in lines))
|
||||
press(state, "enter")
|
||||
self.assertEqual(state.exit_code, 1)
|
||||
|
||||
def test_space_and_b_never_put_it(self):
|
||||
_root, bios = _app_card(self, "muOS")
|
||||
state = _installed_state(bios)
|
||||
self.assertEqual(press(state, "down", " "), [])
|
||||
self.assertFalse(state.app_running)
|
||||
press(state, "esc")
|
||||
self.assertEqual(state.exit_code, 0)
|
||||
|
||||
def test_no_offer_where_it_cannot_go(self):
|
||||
_root, muos = _app_card(self, "muOS")
|
||||
_root, onion = _app_card(self, "Onion")
|
||||
cases = {
|
||||
"failed install": _installed_state(muos, 1, [{"dest": "a", "reason": "x"}]),
|
||||
"plain folder": _installed_state(SYSTEM_DIR),
|
||||
"no launcher": _installed_state(onion),
|
||||
}
|
||||
offline = _installed_state(muos)
|
||||
for name, state in cases.items():
|
||||
with self.subTest(case=name):
|
||||
self.assertEqual(install.tui_rows(state), [])
|
||||
os.environ[install.OFFLINE_VARIABLE] = "1"
|
||||
self.assertEqual(install.tui_rows(offline), [])
|
||||
os.environ.pop(install.OFFLINE_VARIABLE)
|
||||
_title, lines = install.tui_outcome(cases["no launcher"])
|
||||
self.assertIn(install.NO_PYTHON_WORDS, lines)
|
||||
|
||||
def test_put_by_the_install_itself_no_second_offer(self):
|
||||
root, bios = _app_card(self, "muOS")
|
||||
state = _installed_state(bios)
|
||||
install._take_event(state, self.fields(root))
|
||||
self.assertEqual(install.tui_rows(state), [])
|
||||
self.assertIn("RetroBIOS is on the muOS card", "\n".join(install.tui_outcome(state)[1]))
|
||||
|
||||
def test_the_options_row_sets_the_flag_of_the_install_only(self):
|
||||
_root, bios = _app_card(self, "muOS")
|
||||
state = _installed_state(bios)
|
||||
state.stack = ["plan", "options"]
|
||||
rows = install.tui_rows(state)
|
||||
row = next(row for row in rows if row.action == "addapp")
|
||||
self.assertEqual((row.label, row.right), ("Put RetroBIOS on this card: no", "--add-app"))
|
||||
state.focus["options"] = rows.index(row)
|
||||
press(state, "enter")
|
||||
self.assertTrue(state.add_app)
|
||||
self.assertIn("--add-app", install.tui_argv(state, "install", "py", "install.py"))
|
||||
for run in ("check", "confirm", "preview"):
|
||||
self.assertNotIn("--add-app", install.tui_argv(state, run, "py", "install.py"))
|
||||
self.assertIn("--add-app", install.tui_command(state))
|
||||
press(state, "left")
|
||||
self.assertFalse(state.add_app)
|
||||
plain = _installed_state(SYSTEM_DIR)
|
||||
plain.stack = ["plan", "options"]
|
||||
self.assertFalse(any(row.action == "addapp" for row in install.tui_rows(plain)))
|
||||
|
||||
def test_the_command_line_flag_ticks_the_row(self):
|
||||
_root, bios = _app_card(self, "ROCKNIX")
|
||||
state, _effects = install.tui_start(_args(add_app=True, platform="rocknix",
|
||||
dest=bios), "linux", detect=False)
|
||||
self.assertTrue(state.add_app)
|
||||
|
||||
|
||||
@unittest.skipUnless(pyte and pty and hasattr(pty, "fork"),
|
||||
"screen tests need pyte (pip install pyte==0.8.2) and a pty")
|
||||
class EndToEnd(unittest.TestCase):
|
||||
@@ -3410,6 +3548,61 @@ class EndToEnd(unittest.TestCase):
|
||||
self.addCleanup(lambda: term.status is None and os.kill(term.pid, 9))
|
||||
return term
|
||||
|
||||
|
||||
def card_repo(self, system: str) -> tuple[Path, Path]:
|
||||
from test_install_app import AppRepository, make_card # noqa: PLC0415
|
||||
|
||||
repo = self.repo(AppRepository)
|
||||
root = repo.root / "card"
|
||||
return root, make_card(root, system)
|
||||
|
||||
def assertAppOnCard(self, root: Path, system: str) -> None:
|
||||
index = json.loads((REPO_ROOT / "install" / "launchers" / "index.json").read_text())
|
||||
targets = install.app_targets(index, install.APP_PLACES[system])
|
||||
for target in targets:
|
||||
data = (root / target["dest"]).read_bytes()
|
||||
self.assertEqual(hashlib.sha256(data).hexdigest(), target["sha256"],
|
||||
target["dest"])
|
||||
|
||||
def test_put_the_app_on_a_muos_card_from_the_result(self):
|
||||
root, bios = self.card_repo("muOS")
|
||||
term = self.start("--platform", "retroarch", "--dest", str(root))
|
||||
term.wait("files to download")
|
||||
term.send("\r")
|
||||
screen = term.wait(install.APP_OFFER)
|
||||
self.assertIn("> Exit", screen)
|
||||
term.send(DOWN, "\r")
|
||||
screen = term.wait("RetroBIOS is on the muOS card")
|
||||
self.assertIn("Archive Manager", screen)
|
||||
term.send("\r")
|
||||
self.assertEqual(term.close(), 0)
|
||||
self.assertAppOnCard(root, "muOS")
|
||||
self.assertEqual(len(list(bios.iterdir())), 5)
|
||||
self.assertIn("RetroBIOS is on the muOS card", term.after())
|
||||
|
||||
def test_put_the_app_on_a_knulli_card_from_the_options(self):
|
||||
root, _bios = self.card_repo("KNULLI")
|
||||
term = self.start("--platform", "knulli", "--dest", str(root))
|
||||
term.wait("files to download")
|
||||
term.send("o")
|
||||
term.wait("Each row is an option")
|
||||
for _step in range(20):
|
||||
if "> Put RetroBIOS on this card: no" in term.settle():
|
||||
break
|
||||
term.send(DOWN)
|
||||
term.send("\r")
|
||||
term.wait("> Put RetroBIOS on this card: yes")
|
||||
term.send("\x1b")
|
||||
term.wait("files to download")
|
||||
term.send("\r")
|
||||
screen = term.wait("RetroBIOS is on the KNULLI card")
|
||||
self.assertIn("start RetroBIOS from Ports", screen)
|
||||
self.assertNotIn(install.APP_OFFER, screen)
|
||||
term.send("\r")
|
||||
self.assertEqual(term.close(), 0)
|
||||
self.assertAppOnCard(root, "KNULLI")
|
||||
self.assertIn("--add-app", term.after())
|
||||
|
||||
def test_three_presses_install_everything(self):
|
||||
repo = self.repo()
|
||||
term = self.start()
|
||||
|
||||
@@ -111,6 +111,47 @@ class ThePortArchive(unittest.TestCase):
|
||||
self.assertIn(f'DEFAULT_INSTALL_SHA256="{bundled}"', install_sh)
|
||||
|
||||
|
||||
class LauncherIndex(unittest.TestCase):
|
||||
"""install/launchers/index.json, which --add-app reads, lists the members of
|
||||
the archives built from the same tree, each with its size and hash."""
|
||||
|
||||
def test_the_committed_list_is_current(self):
|
||||
import generate_launcher_index # noqa: PLC0415
|
||||
|
||||
text = generate_launcher_index.render(generate_launcher_index.build_index())
|
||||
self.assertEqual(
|
||||
generate_launcher_index.OUTPUT.read_text(encoding="utf-8"), text,
|
||||
"install/launchers/index.json is stale: run "
|
||||
"python scripts/generate_launcher_index.py")
|
||||
|
||||
def test_every_member_of_the_archives_is_listed(self):
|
||||
import generate_launcher_index # noqa: PLC0415
|
||||
|
||||
index = json.loads(generate_launcher_index.OUTPUT.read_text(encoding="utf-8"))
|
||||
places = {place.key: place for place in install.APP_PLACES.values()}
|
||||
for key, archive in generate_launcher_index.LAUNCHER_ARCHIVES.items():
|
||||
with self.subTest(key=key):
|
||||
launcher = index["launchers"][key]
|
||||
listed = launcher.get("members") or launcher["files"]
|
||||
names = [entry.get("name") or entry["dest"] for entry in listed]
|
||||
prefix = "" if "members" in launcher else places[key].folder + "/"
|
||||
self.assertEqual(sorted(names), sorted(
|
||||
prefix + name for name, _path in build_port.archive_members(archive)))
|
||||
for entry in listed:
|
||||
if "repo_path" in entry:
|
||||
data = (REPO_ROOT / entry["repo_path"]).read_bytes()
|
||||
self.assertEqual(hashlib.sha256(data).hexdigest(), entry["sha256"])
|
||||
|
||||
def test_the_list_refuses_an_installer_the_bootstraps_do_not_pin(self):
|
||||
import generate_launcher_index # noqa: PLC0415
|
||||
|
||||
root = _tempdir(self)
|
||||
shutil.copyfile(REPO_ROOT / "install.py", root / "install.py")
|
||||
(root / "install.sh").write_text('DEFAULT_INSTALL_SHA256="' + "0" * 64 + '"\n')
|
||||
with self.assertRaises(ValueError):
|
||||
generate_launcher_index.build_index(root)
|
||||
|
||||
|
||||
class EventRecords(unittest.TestCase):
|
||||
"""rbevents turns --events into fields the FIFO carries intact."""
|
||||
|
||||
|
||||
Reference in new issue
Block a user