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] 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."""