From f3c816eea5e2d881884a4bb32b3464311d64395e Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Mon, 26 Oct 2026 21:01:45 +0100
Subject: [PATCH 1/2] 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.
---
install.ps1 | 2 +-
install.py | 526 ++++++++++++++++++++++++++++-
install.sh | 2 +-
install/launchers/index.json | 211 ++++++++++++
schemas/install-events.schema.json | 53 +++
scripts/generate_event_schema.py | 2 +-
scripts/generate_launcher_index.py | 132 ++++++++
tests/test_install_app.py | 470 ++++++++++++++++++++++++++
tests/test_install_tui.py | 195 ++++++++++-
tests/test_port.py | 41 +++
10 files changed, 1622 insertions(+), 12 deletions(-)
create mode 100644 install/launchers/index.json
create mode 100644 scripts/generate_launcher_index.py
create mode 100644 tests/test_install_app.py
diff --git a/install.ps1 b/install.ps1
index 24449baef..cbe499f8a 100644
--- a/install.ps1
+++ b/install.ps1
@@ -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 }
diff --git a/install.py b/install.py
index 35dc45189..8bb9cbe0a 100644
--- a/install.py
+++ b/install.py
@@ -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
diff --git a/install.sh b/install.sh
index 99e21a37b..a3a63ccaa 100755
--- a/install.sh
+++ b/install.sh
@@ -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() {
diff --git a/install/launchers/index.json b/install/launchers/index.json
new file mode 100644
index 000000000..968e4f42f
--- /dev/null
+++ b/install/launchers/index.json
@@ -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
+ }
+ ]
+ }
+ }
+}
diff --git a/schemas/install-events.schema.json b/schemas/install-events.schema.json
index 3e437afc0..02bafac05 100644
--- a/schemas/install-events.schema.json
+++ b/schemas/install-events.schema.json
@@ -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,
diff --git a/scripts/generate_event_schema.py b/scripts/generate_event_schema.py
index e3579c4a6..7fb77bbd1 100644
--- a/scripts/generate_event_schema.py
+++ b/scripts/generate_event_schema.py
@@ -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),
}
diff --git a/scripts/generate_launcher_index.py b/scripts/generate_launcher_index.py
new file mode 100644
index 000000000..7da8c7103
--- /dev/null
+++ b/scripts/generate_launcher_index.py
@@ -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)
diff --git a/tests/test_install_app.py b/tests/test_install_app.py
new file mode 100644
index 000000000..5814e7a41
--- /dev/null
+++ b/tests/test_install_app.py
@@ -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()
diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py
index aa81f1b87..c34c290f0 100644
--- a/tests/test_install_tui.py
+++ b/tests/test_install_tui.py
@@ -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()
diff --git a/tests/test_port.py b/tests/test_port.py
index 82f797441..269d4023e 100644
--- a/tests/test_port.py
+++ b/tests/test_port.py
@@ -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."""
From 4ea9ee1712f93556c329b711b69deb59dd987c66 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Mon, 26 Oct 2026 21:01:45 +0100
Subject: [PATCH 2/2] docs: describe the app put on a card
---
scripts/sitediagrams.py | 2 +-
wiki/installer.md | 38 ++++++++++++++++++++++++++++++++++----
wiki/testing-guide.md | 2 +-
3 files changed, 36 insertions(+), 6 deletions(-)
diff --git a/scripts/sitediagrams.py b/scripts/sitediagrams.py
index e6ae558cf..139d5519e 100644
--- a/scripts/sitediagrams.py
+++ b/scripts/sitediagrams.py
@@ -877,7 +877,7 @@ def options_table(markdown: str) -> list[str]:
GLANCE_HEADING = "## Options at a glance"
GLANCE_GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = (
("Where and for what", ("--platform", "--dest", "--target", "--every-board", "--save-dir",
- "--romm-firmware", "--standalone-copies")),
+ "--romm-firmware", "--standalone-copies", "--add-app")),
("Fewer files", ("--system", "--core", "--emulator", "--region", "--no-game-data",
"--declared-only", "--installed-cores", "--cores-dir")),
("Look first", ("--check", "--dry-run", "--list-platforms", "--list-targets",
diff --git a/wiki/installer.md b/wiki/installer.md
index e458f6dd9..872474676 100644
--- a/wiki/installer.md
+++ b/wiki/installer.md
@@ -138,7 +138,10 @@ command line** gives the same choice as one command for a script. **Preview the
file list** shows what would be downloaded and writes nothing. **Copy the BIOS
files from your old card or a folder** opens the folder browser, mounted cards
first, and adds [`--from`](#copy-from-an-old-card): the plan then says how many
-files that folder holds and how many are left to download. Files the BIOS
+files that folder holds and how many are left to download. After an install
+on a muOS, KNULLI or ROCKNIX card, the result offers **Put RetroBIOS on this
+card (update from the console later)**, and **Options** has the same row
+([On a handheld](#on-a-handheld-the-retrobios-port)). Files the BIOS
folder holds at the wrong place are counted on the plan, and **Show the files
in the wrong place** lists each one with its reason. Back on the plan
after a change of options, the folder is checked again, so its numbers are the
@@ -716,6 +719,7 @@ what the options name, everything by default.
| `--jobs N`, `-j N` | Parallel downloads, 1 to 32, default 8 |
| `--verbose`, `-v` | Print per-attempt failures and hash mismatches |
| `--standalone-copies` | Also copy into detected standalone emulator directories |
+| `--add-app` | 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. Refused (code 2) for a folder on no such card, and with `--check`, `--dry-run`, a listing, `--from-pack` and `--offline`. See [On a handheld](#on-a-handheld-the-retrobios-port) |
| `--help`, `-h` | Print the option list, a few examples in the form the run was started with, and the address of this page |
`--check` reads the same file list, hashes the destination and exits, with
@@ -1143,6 +1147,31 @@ spruceOS, MinUI, muOS, Knulli or ROCKNIX card by itself. For a card it does
not recognise, add `--platform --dest `.
This works for every system above too.
+**The app, from a computer.** After an install on a muOS, KNULLI or ROCKNIX
+card, the installer can put RetroBIOS on that card too, so the console checks
+and updates its BIOS files later. The result screen offers **Put RetroBIOS on
+this card (update from the console later)**, **Options** has the same row, the
+line questions ask once (`[y/N]`), and a script adds
+[`--add-app`](#option-add-app). Nothing is written without that choice.
+
+| System | Written on the card | Then, on the console |
+|--------|---------------------|----------------------|
+| muOS | `ARCHIVE/RetroBIOS.muxapp`, which the Archive Manager lists | install it from the Archive Manager, start it from Applications |
+| KNULLI | `roms/ports/RetroBIOS.sh`, `RetroBIOS.sh.keys` and the `retrobios` folder, on the SHARE partition | update the game list, start it from Ports |
+| ROCKNIX | `.config/modules/RetroBIOS.sh`, executable, and the `retrobios` folder, on the STORAGE partition | start it from Tools |
+
+The files are the launchers above, from the same ref as the file lists:
+`install/launchers/index.json` lists each one with its size and SHA-256, and
+every file is fetched and checked before the first is written. The muOS
+archive holds the members of `RetroBIOS.muxapp`, stored rather than
+compressed, and is checked against the hash the list records for it. A file
+already in place with that content is left alone, so a second run writes
+nothing. A different one is moved to `retrobios-backup` first, as a BIOS file
+is. Onion, MinUI, NextUI, MyMinUI and Allium have no Python to run it, and the
+result says so in one line. spruceOS and CrossMix have no launcher yet. With
+`--from-pack` or `--offline` the app is not offered, the launchers coming from
+the network.
+
## Where the files come from
Each manifest entry names either `repo_path`, served from the repository at the
@@ -1255,8 +1284,8 @@ fields, the installer refuses to narrow rather than install everything.
| Code | Meaning |
|------|---------|
| `0` | everything asked for is installed or already up to date |
-| `1` | a failure: the one-liner could not download or verify `install.py`, or found no Python 3.8 (nothing was run), an unusable base URL, an unknown platform or target, `--installed-cores` with no core found, a manifest that fails validation, a fetch that never succeeded, no platform to install for in a non-interactive run, some files failed while others installed, `--check` found files to fetch, a `--from-pack` pack that cannot be read, lacks a part or is for another platform, a `--from` folder that does not exist or cannot be read, or `--restore` could not put a file back |
-| `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one), the drive has too little free space for the download (checked before any folder is created), or the question about a folder that does not exist, named with `--dest` or typed at the folder question, was answered no. Also an option the installer refused before any download: an unknown option, `--jobs` outside 1 to 32, `--target` with `--every-board` or `--from-pack`, `--target` or `--list-targets` with `--offline` (or `RETROBIOS_INSTALL_OFFLINE` set), which read a core list from the network. The message then starts with `install.py: error:`. With `--restore`, a folder that is not a dated folder inside `retrobios-backup` |
+| `1` | a failure: the one-liner could not download or verify `install.py`, or found no Python 3.8 (nothing was run), an unusable base URL, an unknown platform or target, `--installed-cores` with no core found, a manifest that fails validation, a fetch that never succeeded, no platform to install for in a non-interactive run, some files failed while others installed, `--check` found files to fetch, a `--from-pack` pack that cannot be read, lacks a part or is for another platform, a `--from` folder that does not exist or cannot be read, `--restore` could not put a file back, or `--add-app` could not put every file of the app on the card |
+| `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one), the drive has too little free space for the download (checked before any folder is created), or the question about a folder that does not exist, named with `--dest` or typed at the folder question, was answered no. Also an option the installer refused before any download: an unknown option, `--jobs` outside 1 to 32, `--target` with `--every-board` or `--from-pack`, `--target` or `--list-targets` with `--offline` (or `RETROBIOS_INSTALL_OFFLINE` set), which read a core list from the network, `--add-app` with `--check`, `--dry-run`, a listing, `--from-pack` or `--offline`. The message then starts with `install.py: error:`. Also `--add-app` for a folder on no card that takes the app, said in an `Error:` line and an `app` event before anything is written. With `--restore`, a folder that is not a dated folder inside `retrobios-backup` |
| `130` | stopped with Ctrl+C, or with `q`, Ctrl+C or Ctrl+D at a question. Run the same command again to carry on |
| `141` | the output was piped to a reader that closed early (`--list-platforms \| head`) |
@@ -1313,9 +1342,10 @@ other schemas under `api/v1/schemas/`.
| `transfer` | `bytes_local`, `bytes_network` | Right after each `progress` event, and once when the files of a `download` event are done. It splits `bytes_done`: `bytes_local` came from this machine (a copy found by `--from` or in the BIOS folder, the pack, a partial copy resumed), `bytes_network` from the network. A download speed is `bytes_network` over the time elapsed |
| `downloaded` | `dest`, `ok`, `reason`, `size`, `files_done`, `files_total`, `bytes_done`, `bytes_total` | Each finished file, downloaded or copied from where a `found` event named it. `reason` says why one failed, and is empty otherwise |
| `summary` | `mode`, `installed`, `up_to_date`, `pending`, `failed`, `skipped`, `left_out`, `kept`, `interrupted` | The outcome. `pending` counts what a next run would still fetch, `failed` lists `{dest, reason}`, `skipped` lists `{reason, label, files, required}` for the files the collection cannot supply, `left_out` lists `{category, label, files, dests}` for what `--no-game-data` left out, and is empty without it. `kept` lists `{folder, files, bytes}`, one per backup folder, for the files in place the run moved aside before replacing them (with `--dry-run`, would move), and is empty when none. Not written for a listing |
+| `app` | `system`, `folder`, `ok`, `reason`, `dests`, `written`, `kept` | After `summary`, with `--add-app` or a yes to its question: the RetroBIOS app on the card of `system` whose root is `folder`. `dests` lists the app's files below that root, `written` the ones this run wrote (empty when each was in place), `kept` the backup folders as in `summary`. `ok` is false, with `reason`, when the card takes no app (before anything is written, code 2), when the install did not finish, or when a file could not be fetched, verified or written (code 1) |
| `end` | `code` | Last line, with the exit status the process returns (see [Exit status](#exit-status)) |
-Schema 1 can gain a kind (`found` came with `--from`, `declared` with `--declared-only`, `unsearched` with the search that goes on past a closed folder), never a key of an
+Schema 1 can gain a kind (`found` came with `--from`, `declared` with `--declared-only`, `unsearched` with the search that goes on past a closed folder, `app` with `--add-app`), never a key of an
existing kind: a reader skips a kind it does not know. An option refused
before the run starts (`install.py: error:`, code 2) writes
no event at all. A program reads a missing `end` as that case, or as a
diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md
index c4a6a26d7..9091f6571 100644
--- a/wiki/testing-guide.md
+++ b/wiki/testing-guide.md
@@ -54,7 +54,7 @@ Every module in `tests/`, by the area it guards.
| Packs and manifests | synthetic + real packs | `test_artifact_lock`, `test_atomic_artifacts`, `test_batocera_cores`, `test_deterministic_zip`, `test_emulator_pack_counts`, `test_finalize_pack_names`, `test_large_file_cache`, `test_libretro_layout`, `test_manifest_entry`, `test_minui_platform`, `test_no_game_data`, `test_pack_conformance_dirs`, `test_pack_core_extras`, `test_pack_counts`, `test_pack_destinations`, `test_pack_failures`, `test_pack_file_names`, `test_pack_grouping`, `test_pack_integrity`, `test_pack_member_identity`, `test_pack_readme`, `test_platform_inheritance`, `test_platform_keys`, `test_release_packs`, `test_required_only_archives`, `test_required_only_twins`, `test_save_root`, `test_split_extras`, `test_split_names`, `test_split_pack`, `test_system_pack_extras`, `test_system_pack_parity`, `test_torrentzip`, `test_which_pack` |
| Slots and regions | synthetic + profiles | `test_region`, `test_region_agnostic_scan`, `test_region_groups`, `test_region_held`, `test_region_platform_index`, `test_region_required_only`, `test_slot`, `test_slots`, `test_slots_shipped_file` |
| Hardware targets and systems | synthetic + target files | `test_extract_targets`, `test_lakka_targets`, `test_libretro_slugs`, `test_system_ids`, `test_system_selection`, `test_target_names`, `test_target_scrapers`, `test_target_systems`, `test_target_verify` |
-| Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_from`, `test_install_offline`, `test_install_selection`, `test_install_tui` |
+| Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_app`, `test_install_from`, `test_install_offline`, `test_install_selection`, `test_install_tui` |
| Handheld launchers | a pty as the console, fake installer and firmware tools, stubbed PortMaster dialogs | `test_handheld_launchers`, `test_port` |
| Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` |
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_components`, `test_site_r3_css`, `test_site_r3_emulators`, `test_site_r3_entry`, `test_site_r3_nav`, `test_site_r3_p0`, `test_site_r3_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_r4_components`, `test_site_r4_css`, `test_site_r4_emulators`, `test_site_r4_entry`, `test_site_r4_merge`, `test_site_r4_p0`, `test_site_r4_reference`, `test_site_r4_search`, `test_site_r4_systems`, `test_site_r4_wiki`, `test_site_r5_components`, `test_site_r5_css`, `test_site_r5_emulators`, `test_site_r5_entry`, `test_site_r5_reference`, `test_site_r5_systems`, `test_site_r5_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |