feat: put the retrobios app on a handheld card

--add-app, the result screen and a [y/N] question put the launcher of
muOS, KNULLI or ROCKNIX on the card the BIOS folder is on. The files come
from install/launchers/index.json at the same ref, each checked by size
and SHA-256 before the first is written, and the app event reports it.
This commit is contained in:
Abdessamad Derraz committed 2026-10-26 21:01:45 +01:00
1 parent fbe5ba4e3e
commit f3c816eea5
10 files changed
+1622 -12

No files matched your search

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