diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index ad11cd62..2787400f 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -17,6 +17,8 @@ on: - "install.py" - "install.sh" - "install.ps1" + # The handheld port bundles install.py and its tests read both. + - "ports/**" # Every input of the site build, so a page that would fail the deploy # fails here first (tests/test_workflow_paths.py). - "wiki/**" @@ -42,6 +44,7 @@ on: - "install.py" - "install.sh" - "install.ps1" + - "ports/**" - "wiki/**" - "docs_assets/**" - "provenance/**" diff --git a/install.ps1 b/install.ps1 index d0b06115..83cb19d8 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "5c12411406dd1c0542f8051eb5c9e5aaff022de208e4c446bfc81cc03896db8a" + $defaultInstallSha256 = "abdbe842d22bcb384824e7ef4dc6464c573662fa87a18a97021c012c617453fa" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 9620a946..af1c5cf2 100644 --- a/install.py +++ b/install.py @@ -363,6 +363,7 @@ class Progress: def __init__(self, total_files: int, total_bytes: int, term: Terminal) -> None: self.total_files = total_files + self.expected_bytes = total_bytes self.total_bytes = max(total_bytes, 1) self.term = term self.files = 0 @@ -370,6 +371,7 @@ class Progress: self.resumed = 0 self.start = time.monotonic() self._drawn = 0.0 + self._evented = self.start self._lock = threading.Lock() def add(self, count: int, resumed: bool = False) -> None: @@ -381,6 +383,10 @@ class Progress: if self.term.live and not self.term.quiet and now - self._drawn >= 0.1: self._drawn = now self._draw() + if EVENTS.on and now - self._evented >= EVENT_PROGRESS_INTERVAL: + self._evented = now + EVENTS.emit("progress", files_done=self.files, files_total=self.total_files, + bytes_done=self.bytes, bytes_total=self.expected_bytes) def forget_resumed(self, count: int) -> None: with self._lock: @@ -450,6 +456,142 @@ class Progress: print(f"\r\033[K{text[:width]}", end="", file=self.term.stream, flush=True) +# --events: one JSON object per line on stdout, for a program driving the +# installer (the handheld port shows its screens from them). Each kind has +# exactly these keys besides "event", in this order, and nothing else. +EVENT_SCHEMA = 1 +EVENT_KEYS: dict[str, tuple[str, ...]] = { + # mode: install, check, dry-run or list. + "start": ("schema", "installer", "mode"), + # platforms: [{"platform", "name", "bios_dir"}], empty when none is found. + "detect": ("os", "os_name", "platforms"), + # stage: manifest (reason: network, not_found or invalid) or detect + # (reason: conflict, two systems found on one machine). + "error": ("stage", "reason", "platform", "message"), + # state: present (hash verified), wrong_hash or missing. + "file": ("platform", "dest", "systems", "size", "state"), + "system": ("platform", "system", "name", "files", "present", "missing", + "wrong_hash", "bytes_needed"), + "check": ("platform", "name", "bios_dir", "files", "present", "missing", + "wrong_hash", "bytes_needed", "data_missing", "not_installable"), + "download": ("platform", "files", "bytes"), + "progress": ("files_done", "files_total", "bytes_done", "bytes_total"), + "downloaded": ("dest", "ok", "reason", "size", "files_done", "files_total", + "bytes_done", "bytes_total"), + # failed: [{"dest", "reason"}]. skipped: [{"reason", "label", "files", + # "required"}], the files the collection cannot supply. + "summary": ("mode", "installed", "up_to_date", "pending", "failed", "skipped", + "interrupted"), + "end": ("code",), +} +# Seconds between two progress events while bytes arrive. +EVENT_PROGRESS_INTERVAL = 0.5 + + +class EventStream: + """The --events output: closed (nothing written) until opened.""" + + def __init__(self) -> None: + self.stream: TextIO | None = None + self._lock = threading.Lock() + + def open(self, stream: TextIO) -> None: + self.stream = stream + + @property + def on(self) -> bool: + return self.stream is not None + + def emit(self, kind: str, **fields: object) -> None: + if self.stream is None: + return + keys = EVENT_KEYS[kind] + if set(fields) != set(keys): + raise ValueError(f"event {kind}: keys {sorted(fields)} != {sorted(keys)}") + line = json.dumps({"event": kind, **{key: fields[key] for key in keys}}) + with self._lock: + self.stream.write(line + "\n") + self.stream.flush() + + +EVENTS = EventStream() + + +def _event_mode(args: argparse.Namespace) -> str: + if args.check: + return "check" + if args.dry_run: + return "dry-run" + if (args.list_platforms or args.list_targets or args.list_systems + or args.list_cores or args.list_emulators): + return "list" + return "install" + + +def _event_exit_code(code: object) -> int: + """The process status sys.exit(code) gives.""" + if code is None: + return 0 + return code if isinstance(code, int) else 1 + + +def _os_name() -> str: + """The OS's own name, from os-release, else the kernel's.""" + release = _parse_os_release() + return release.get("PRETTY_NAME") or release.get("NAME") or platform.system() + + +def _emit_check( + plat: str, manifest: dict, bios_path: Path, files: list[dict], up_to_date: list[dict], + mismatched: list[dict], data_missing: int, not_installable: int, +) -> None: + """One event per file, per system and per platform after a check.""" + present = {id(entry) for entry in up_to_date} + wrong = {id(entry) for entry in mismatched} + systems: dict[str, dict[str, int]] = {} + for entry in files: + state = ("present" if id(entry) in present + else "wrong_hash" if id(entry) in wrong else "missing") + size = entry.get("size", 0) + EVENTS.emit("file", platform=plat, dest=entry["dest"], + systems=entry_systems(entry), size=size, state=state) + for system in entry_systems(entry) or [OTHER_SYSTEMS]: + counts = systems.setdefault(system, { + "files": 0, "present": 0, "missing": 0, "wrong_hash": 0, + "bytes_needed": 0, + }) + counts["files"] += 1 + counts[state] += 1 + if state != "present": + counts["bytes_needed"] += size + names = manifest.get("system_names") or {} + for system in sorted(systems): + EVENTS.emit("system", platform=plat, system=system, + name=", ".join(names.get(system) or [system]), **systems[system]) + needed = [entry for entry in files if id(entry) not in present] + EVENTS.emit( + "check", platform=plat, name=display_name(plat, manifest), bios_dir=str(bios_path), + files=len(files), present=len(up_to_date), missing=len(needed) - len(mismatched), + wrong_hash=len(mismatched), + bytes_needed=sum(entry.get("size", 0) for entry in needed), + data_missing=data_missing, not_installable=not_installable, + ) + + +def _skipped(omitted: list[dict]) -> list[dict]: + """The files the collection cannot supply, grouped by reason.""" + groups: dict[str, dict] = {} + for entry in omitted: + reason = entry.get("reason", "unknown") + group = groups.setdefault(reason, { + "reason": reason, "label": OMISSION_REASONS.get(reason, reason), + "files": 0, "required": 0, + }) + group["files"] += 1 + group["required"] += bool(entry.get("required", True)) + return [groups[reason] for reason in sorted(groups)] + + # The three RetroArch packages published for Android, newest ABI first. ANDROID_RETROARCH_PACKAGES = ( "com.retroarch.aarch64", @@ -2645,9 +2787,15 @@ def fetch_manifest(plat: str) -> dict: try: return _validate_manifest(_fetch_json(url, MAX_MANIFEST_BYTES, "manifest"), plat) except ValueError as exc: + EVENTS.emit("error", stage="manifest", reason="invalid", platform=plat, + message=f"the file list is not valid: {exc}") print(f" The file list for {plat} is not valid: {exc}", file=sys.stderr) sys.exit(1) except urllib.error.HTTPError as exc: + EVENTS.emit( + "error", stage="manifest", reason="not_found" if exc.code == 404 else "network", + platform=plat, message=f"the file list could not be fetched: HTTP {exc.code}", + ) if exc.code == 404 and os.environ.get("RETROBIOS_BASE_URL"): print(f" No file list for {plat} there.", file=sys.stderr) for line in _address_lines(url): @@ -2671,6 +2819,9 @@ def fetch_manifest(plat: str) -> dict: print(NETWORK_ADVICE, file=sys.stderr) sys.exit(1) except (urllib.error.URLError, OSError) as exc: + EVENTS.emit("error", stage="manifest", reason="network", platform=plat, + message=f"the file list could not be fetched: " + f"{getattr(exc, 'reason', None) or exc}") print(f" Could not fetch the file list for {plat} after {MAX_RETRIES} " f"attempts: {getattr(exc, 'reason', None) or exc}", file=sys.stderr) for line in _address_lines(url): @@ -4093,6 +4244,12 @@ def download_files( if _stop.is_set(): continue progress.done(dest, success, reason) + EVENTS.emit( + "downloaded", dest=dest, ok=success, reason=reason, + size=future_map[future].get("size", 0), files_done=progress.files, + files_total=progress.total_files, bytes_done=progress.bytes, + bytes_total=progress.expected_bytes, + ) if not success: failed.append((dest, reason)) if reason == NOT_WRITABLE and not _folder_writable(bios_path): @@ -5217,6 +5374,12 @@ def main() -> None: action="store_true", help="print only errors and the final summary (implies --no-input)", ) + parser.add_argument( + "--events", + action="store_true", + help="write the run as JSON events on stdout, one per line, and the " + "usual text on stderr (implies --no-input)", + ) parser.add_argument( "--version", action="version", @@ -5247,6 +5410,29 @@ def main() -> None: parser.error("--quiet applies to an install, not to --check, --dry-run or a listing") if args.every_board and args.target: parser.error("--every-board installs every board; --target names one") + if args.events: + # The events own stdout. The text a person reads goes to stderr, so a + # program never has to tell one from the other. + args.no_input = True + EVENTS.open(sys.stdout) + sys.stdout = sys.stderr + EVENTS.emit("start", schema=EVENT_SCHEMA, installer=installer_id(), + mode=_event_mode(args)) + try: + _run(args) + except SystemExit as exc: + EVENTS.emit("end", code=_event_exit_code(exc.code)) + raise + except KeyboardInterrupt: + EVENTS.emit("end", code=130) + raise + EVENTS.emit("end", code=0) + return + _run(args) + + +def _run(args: argparse.Namespace) -> None: + """The run, its text quiet or not.""" if not args.quiet: _install(args, sys.stdout) return @@ -5487,6 +5673,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: platforms = detect_platforms(os_type) if not platforms: print(" No supported platform detected.") + EVENTS.emit("detect", os=os_type, os_name=_os_name(), platforms=[]) # Standalone emulators read their BIOS from folders of their own, # which the file lists name: an ES-DE user with DuckStation and # PCSX2 was sent to RetroArch's folder, which nothing here reads. @@ -5513,11 +5700,20 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: if conflict and not interactive and not listing: # A machine runs one system: installing to each folder would write # where nothing reads, and picking one would be a guess. - print(f"Error: {' and '.join(conflict)} are both detected here, and a " - "machine runs one. Name the folder with --dest (and --platform for " + message = (f"{' and '.join(conflict)} are both detected here, and a " + "machine runs one") + EVENTS.emit("error", stage="detect", reason="conflict", platform="", + message=message) + print(f"Error: {message}. Name the folder with --dest (and --platform for " "its file list).", file=sys.stderr) sys.exit(1) + if platforms: + EVENTS.emit("detect", os=os_type, os_name=_os_name(), platforms=[ + {"platform": name, "name": display_name(name), "bios_dir": str(path)} + for name, path in platforms + ]) + if len(platforms) > 1 and not listing and interactive: platforms = _prompt_platform_choice(platforms) @@ -5534,6 +5730,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: total_seconds = 0.0 total_declined = 0 check_pending = 0 + total_verified = 0 + files_pending = 0 failures: list[tuple[str, str]] = [] installed_paths: list[Path] = [] @@ -5801,6 +5999,15 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: ) ) data_dirs = select_data_directories(manifest, files, narrowed) + data_states = [ + (entry, data_directory_state(entry, bios_path)) for entry in data_dirs + ] if args.check or EVENTS.on else [] + if EVENTS.on: + _emit_check( + plat_name, manifest, bios_path, files, up_to_date, mismatched, + sum(len(state[1]) for _entry, state in data_states), len(omitted_files), + ) + total_verified += len(up_to_date) if args.check: if to_download: @@ -5833,8 +6040,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: every=args.verbose, ): print(line) - for entry in data_dirs: - _root, needed, present = data_directory_state(entry, bios_path) + for entry, (_root, needed, present) in data_states: if needed: print(f" {entry['ref']}: {present}/{len(entry['files'])} data files " f"present, {len(needed)} to fetch") @@ -5842,6 +6048,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: continue if args.dry_run: + files_pending += len(to_download) _print_plan(to_download, bios_path, term, [ (entry, data_directory_state(entry, bios_path)[1]) for entry in data_dirs ]) @@ -5861,7 +6068,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: "install less (see --help).") total_up_to_date += len(up_to_date) total_declined += len(to_download) + files_pending += len(to_download) continue + EVENTS.emit("download", platform=plat_name, files=len(to_download), bytes=dl_size) print(f"\nDownloading {len(to_download)} files ({format_size(dl_size)})...") print(" Press Ctrl+C to stop. Running the same command again " "carries on from here.") @@ -5889,6 +6098,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: term=term, progress=progress, ) total_downloaded += len(to_download) - len(failed) + files_pending += len(failed) + if interrupted: + files_pending += max(len(to_download) - progress.files, 0) total_errors += len(failed) failures.extend(failed) total_bytes += progress.transferred() @@ -5955,6 +6167,14 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: "they read into them.") sys.stdout = real_stdout + if not listing: + EVENTS.emit( + "summary", mode=_event_mode(args), installed=total_downloaded, + up_to_date=total_verified if args.check or args.dry_run else total_up_to_date, + pending=check_pending if args.check else files_pending, + failed=[{"dest": dest, "reason": str(reason)} for dest, reason in failures], + skipped=_skipped(omitted_seen), interrupted=total_interrupted, + ) # A check scripted as `--check && launch` reads the exit code only. if args.check and check_pending: sys.exit(1) diff --git a/install.sh b/install.sh index 4352e13c..ba3ef375 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="5c12411406dd1c0542f8051eb5c9e5aaff022de208e4c446bfc81cc03896db8a" +DEFAULT_INSTALL_SHA256="abdbe842d22bcb384824e7ef4dc6464c573662fa87a18a97021c012c617453fa" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/ports/RetroBIOS/README.md b/ports/RetroBIOS/README.md new file mode 100644 index 00000000..eb023ef9 --- /dev/null +++ b/ports/RetroBIOS/README.md @@ -0,0 +1,18 @@ +## Notes + +RetroBIOS runs the RetroBIOS installer (`install.py`, from +[github.com/Abdess/retrobios](https://github.com/Abdess/retrobios)) on the +device. The installer finds the BIOS folder of the system, checks every file +its emulators read against its hash, and downloads the missing ones. The +screens are PortMaster's own. + +Needs a network connection. Python 3.8 or newer: the system's, or +PortMaster's `python_3.11` runtime on aarch64, downloaded on first use. + +## Controls + +| Button | Action | +|--|--| +| D-Pad | Move in a list | +| A | Choose | +| B | Back, on PortMaster releases that read it (every list also has a Back or Quit entry) | diff --git a/ports/RetroBIOS/RetroBIOS.sh b/ports/RetroBIOS/RetroBIOS.sh new file mode 100755 index 00000000..59928003 --- /dev/null +++ b/ports/RetroBIOS/RetroBIOS.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# RetroBIOS: checks the BIOS folder of this system and downloads the files +# it lacks, with PortMaster's own screens. + +XDG_DATA_HOME=${XDG_DATA_HOME:-$HOME/.local/share} + +if [ -d "/opt/system/Tools/PortMaster/" ]; then + controlfolder="/opt/system/Tools/PortMaster" +elif [ -d "/opt/tools/PortMaster/" ]; then + controlfolder="/opt/tools/PortMaster" +elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then + controlfolder="$XDG_DATA_HOME/PortMaster" +else + controlfolder="/roms/ports/PortMaster" +fi + +source "$controlfolder/control.txt" +[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt" +get_controls + +GAMEDIR="/$directory/ports/retrobios" +cd "$GAMEDIR" || exit 1 + +> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1 + +# PortMasterDialog.txt has shipped with PortMaster since before 2024.02.26, +# and the 8.5.23 copy JELOS and KOLOS preinstall has no funcs.txt to source +# it: it is sourced here. +if [ ! -f "$controlfolder/PortMasterDialog.txt" ]; then + echo "RetroBIOS needs PortMaster 2024.02.26 or newer: update PortMaster, then start RetroBIOS again." + exit 1 +fi +source "$controlfolder/PortMasterDialog.txt" +source "$GAMEDIR/screens.txt" + +export SDL_GAMECONTROLLERCONFIG="$sdl_controllerconfig" + +rb_main +status=$? + +if declare -F pm_finish > /dev/null; then + pm_finish +fi +exit $status diff --git a/ports/RetroBIOS/gameinfo.xml b/ports/RetroBIOS/gameinfo.xml new file mode 100644 index 00000000..4eb01b2f --- /dev/null +++ b/ports/RetroBIOS/gameinfo.xml @@ -0,0 +1,11 @@ + + + + ./RetroBIOS.sh + RetroBIOS + Checks the BIOS folder of this system against the files its emulators read, and downloads the missing ones from the RetroBIOS collection, each verified by its hash. + Abdess + Open Source + Utility + + diff --git a/ports/RetroBIOS/port.json b/ports/RetroBIOS/port.json new file mode 100644 index 00000000..083ef871 --- /dev/null +++ b/ports/RetroBIOS/port.json @@ -0,0 +1,31 @@ +{ + "version": 4, + "name": "retrobios.zip", + "items": [ + "RetroBIOS.sh", + "retrobios" + ], + "items_opt": [], + "attr": { + "title": "RetroBIOS", + "porter": [ + "Abdess" + ], + "desc": "Checks the BIOS folder of this system against the files its emulators read, and downloads the missing ones from the RetroBIOS collection, each verified by its hash.", + "desc_md": null, + "inst": "Needs a network connection: the list of files and the files come from the internet. Uses the system's Python 3.8 or newer, or PortMaster's python_3.11 runtime on aarch64.", + "inst_md": null, + "genres": [ + "other" + ], + "image": null, + "rtr": true, + "exp": false, + "runtime": [], + "store": [], + "availability": "full", + "reqs": [], + "arch": [], + "min_glibc": "" + } +} diff --git a/ports/RetroBIOS/retrobios/rbevents.py b/ports/RetroBIOS/retrobios/rbevents.py new file mode 100644 index 00000000..47bfbee5 --- /dev/null +++ b/ports/RetroBIOS/retrobios/rbevents.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Turn install.py --events into tab-separated records for the launcher. + +The launcher is bash and draws through PortMaster's dialog FIFO, so it reads +one record per line with `IFS=$'\\t' read`. Text fields are cleaned for that +FIFO: PortMasterDialog sends its arguments through an unquoted +`echo $(printf ...)`, where a tab or newline splits a field and `*`, `?` or +`[` expand as a file pattern. + + rbevents.py status EVENTS records of a --check run + rbevents.py follow SUMMARY an install run on stdin: progress + records on stdout, the outcome in SUMMARY + +Python 3.8 and the standard library, like install.py. +""" +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path +from typing import Iterable, Iterator, TextIO + +_UNSAFE = re.compile(r"[\x00-\x1f\x7f*?\[\]\\]+") +_SPACES = re.compile(r" {2,}") +# A system id becomes a bash array subscript and a menu key in the launcher. +# Every manifest uses slugs; anything else is dropped, never cleaned into one. +_SYSTEM_ID = re.compile(r"[A-Za-z0-9._-]+") + + +def clean(value: object) -> str: + """One FIFO-safe field: no control character, pattern character or run of spaces.""" + text = _UNSAFE.sub(" ", str(value)) + return _SPACES.sub(" ", text).strip() + + +def size_text(count: int) -> str: + """12 KB, 3.4 MB, 1.2 GB.""" + value = float(count) + for unit in ("B", "KB", "MB", "GB"): + if value < 1024 or unit == "GB": + return f"{value:.0f} {unit}" if unit in ("B", "KB") else f"{value:.1f} {unit}" + value /= 1024 + return f"{value:.1f} GB" + + +def events(lines: Iterable[str]) -> Iterator[dict]: + """The JSON objects of an event stream. A line that is not one is skipped.""" + for line in lines: + line = line.strip() + if not line.startswith("{"): + continue + try: + event = json.loads(line) + except ValueError: + continue + if isinstance(event, dict) and isinstance(event.get("event"), str): + yield event + + +def record(out: TextIO, *fields: object) -> None: + # A tab is whitespace to `read`, so two in a row are one separator: an + # empty field would shift the ones after it. "-" stands for it. + out.write("\t".join(clean(field) or "-" for field in fields) + "\n") + out.flush() + + +def status(stream: Iterable[str], out: TextIO) -> None: + """The records the start screen and the menus read. + + Only the first detected platform is described: a handheld runs one. + """ + platform = None + ended = False + for event in events(stream): + kind = event["event"] + if kind == "start": + record(out, "start", event.get("installer", "")) + elif kind == "detect": + found = event.get("platforms") or [] + record(out, "detect", event.get("os_name", ""), len(found)) + if found: + first = found[0] + platform = first.get("platform") + record(out, "platform", platform, first.get("name", ""), + first.get("bios_dir", "")) + elif kind == "error": + record(out, "error", event.get("reason", ""), event.get("message", "")) + elif kind == "system" and event.get("platform") == platform: + if not _SYSTEM_ID.fullmatch(str(event.get("system", ""))): + continue + needed = event.get("missing", 0) + event.get("wrong_hash", 0) + system = event.get("system", "") + # install.py files what belongs to no single system under "other". + name = "Files of no single system" if system == "other" else event.get("name", "") + record(out, "system", system, name, + event.get("files", 0), event.get("present", 0), event.get("missing", 0), + event.get("wrong_hash", 0), size_text(event.get("bytes_needed", 0)), + "1" if needed else "0") + elif kind == "check" and event.get("platform") == platform: + record(out, "check", event.get("files", 0), event.get("present", 0), + event.get("missing", 0), event.get("wrong_hash", 0), + size_text(event.get("bytes_needed", 0)), event.get("data_missing", 0), + event.get("not_installable", 0)) + elif kind == "end": + ended = True + record(out, "end", event.get("code", 1)) + if not ended: + # Refused before the run started, or stopped without a word. + record(out, "end", "none") + + +def follow(stream: Iterable[str], out: TextIO, summary: TextIO) -> None: + """Progress records as an install runs, and its outcome in *summary*.""" + ended = False + for event in events(stream): + kind = event["event"] + if kind == "download": + record(out, "download", event.get("files", 0), size_text(event.get("bytes", 0))) + elif kind == "downloaded": + record(out, "file", event.get("files_done", 0), event.get("files_total", 0), + "ok" if event.get("ok") else "failed", event.get("dest", ""), + size_text(event.get("bytes_done", 0)), + size_text(event.get("bytes_total", 0))) + elif kind == "progress": + record(out, "progress", event.get("files_done", 0), event.get("files_total", 0), + size_text(event.get("bytes_done", 0)), + size_text(event.get("bytes_total", 0))) + elif kind == "error": + record(out, "error", event.get("reason", ""), event.get("message", "")) + record(summary, "error", event.get("reason", ""), event.get("message", "")) + elif kind == "summary": + record(summary, "summary", event.get("installed", 0), event.get("up_to_date", 0), + event.get("pending", 0), "1" if event.get("interrupted") else "0") + for failure in event.get("failed") or []: + record(summary, "failed", failure.get("dest", ""), failure.get("reason", "")) + for group in event.get("skipped") or []: + record(summary, "skipped", group.get("label", ""), group.get("files", 0), + group.get("required", 0)) + elif kind == "end": + ended = True + record(summary, "end", event.get("code", 1)) + if not ended: + record(summary, "end", "none") + + +def main(argv: list[str]) -> int: + if len(argv) == 2 and argv[0] == "status": + with open(argv[1], encoding="utf-8", errors="replace") as stream: + status(stream, sys.stdout) + return 0 + if len(argv) == 2 and argv[0] == "follow": + with Path(argv[1]).open("w", encoding="utf-8") as summary: + follow(sys.stdin, sys.stdout, summary) + return 0 + sys.stderr.write("usage: rbevents.py status EVENTS | follow SUMMARY\n") + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/ports/RetroBIOS/retrobios/screens.txt b/ports/RetroBIOS/retrobios/screens.txt new file mode 100644 index 00000000..812814a4 --- /dev/null +++ b/ports/RetroBIOS/retrobios/screens.txt @@ -0,0 +1,467 @@ +#!/bin/bash +# RetroBIOS screens, drawn by PortMaster's own GUI (pugwash fifo_control) +# through the functions of PortMasterDialog.txt. Sourced by RetroBIOS.sh +# after PortMasterDialog.txt. +# +# Only what PortMaster 2024.02.26 and the 8.5.23 copy JELOS and KOLOS ship +# is used: PortMasterDialogInit, PortMasterDialog, PortMasterDialogResult, +# PortMasterDialogExit, PortMasterIPCheck, and the commands +# register_set_info, register_clear, selection_list, messages_begin, +# message, messages_end, progress, progress_clear and check_runtime. +# Those releases ignore --want-cancel, so every list ends on its own Back +# or Quit entry. They also read "\n" literally, so no text has a newline, +# and their wait for an answer gives up after 30 seconds: rb_answer keeps +# waiting while pugwash is alive. +# +# Inputs: GAMEDIR, controlfolder, ESUDO, and RB_INSTALL (install.py). + +RB_STATE="${RB_STATE:-$GAMEDIR/run}" +RB_LOG="${RB_LOG:-$GAMEDIR/log.txt}" +RB_INSTALL="${RB_INSTALL:-$GAMEDIR/install.py}" +RB_EVENTS="${RB_EVENTS:-$GAMEDIR/rbevents.py}" +RB_RUNTIME="python_3.11" +RB_PC_ROUTE="On a computer, with this SD card mounted, the same installer fills the card: install.py --platform NAME --dest BIOS-FOLDER-ON-THE-CARD. Instructions: abdess.github.io/retrobios/wiki/installer" +RB_PYTHON="" +RB_PYTHONHOME="" +RB_MOUNTED="" +RB_PYTHON_PROBLEM="" + +rb_arch() { + uname -m +} + +rb_py() { + if [ -n "$RB_PYTHONHOME" ]; then + PYTHONHOME="$RB_PYTHONHOME" "$RB_PYTHON" "$@" + else + "$RB_PYTHON" "$@" + fi +} + +# --- Python --------------------------------------------------------------- + +rb_python_ok() { + "$@" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)' >/dev/null 2>&1 +} + +# The OS python3 when it is 3.8 or newer, else PortMaster's python_3.11 +# runtime on aarch64, mounted as PortMaster's own Python ports do. Sets +# RB_PYTHON_PROBLEM when neither can run install.py. +rb_select_python() { + if command -v python3 >/dev/null 2>&1 && rb_python_ok python3; then + RB_PYTHON="python3" + return 0 + fi + if [ "$(rb_arch)" != "aarch64" ]; then + RB_PYTHON_PROBLEM="old" + return 1 + fi + rb_runtime +} + +rb_runtime() { + local python_dir="$HOME/$RB_RUNTIME" + local squashfs="$controlfolder/libs/$RB_RUNTIME.squashfs" + if [ ! -f "$squashfs" ]; then + if [ -z "$(PortMasterIPCheck)" ]; then + RB_PYTHON_PROBLEM="runtime_offline" + return 1 + fi + PortMasterDialogInit "no-check" + PortMasterDialog "messages_begin" + PortMasterDialog "message" "This system's Python is older than 3.8. Downloading PortMaster's $RB_RUNTIME runtime." + local answer + answer=$(rb_answer "check_runtime" "$RB_RUNTIME.squashfs") + PortMasterDialog "messages_end" + PortMasterDialogExit + if [ "$answer" != "OKAY" ] || [ ! -f "$squashfs" ]; then + RB_PYTHON_PROBLEM="runtime_failed" + return 1 + fi + fi + mkdir -p "$python_dir" + if [ "${PM_CAN_MOUNT:-Y}" != "N" ]; then + $ESUDO umount "$python_dir" >/dev/null 2>&1 + fi + if ! $ESUDO mount "$squashfs" "$python_dir"; then + RB_PYTHON_PROBLEM="runtime_failed" + return 1 + fi + RB_MOUNTED="$python_dir" + RB_PYTHON="$python_dir/bin/python3" + RB_PYTHONHOME="$python_dir" + if ! PYTHONHOME="$python_dir" rb_python_ok "$RB_PYTHON"; then + RB_PYTHON_PROBLEM="runtime_failed" + return 1 + fi + return 0 +} + +rb_release_python() { + if [ -n "$RB_MOUNTED" ] && [ "${PM_CAN_MOUNT:-Y}" != "N" ]; then + $ESUDO umount "$RB_MOUNTED" >/dev/null 2>&1 + fi + RB_MOUNTED="" +} + +# --- Dialog primitives ---------------------------------------------------- + +# A command that waits for the user, and its answer. PortMasterDialogResult +# stops waiting after 30 seconds on the older releases and then returns +# WAIT: the answer is read again until pugwash writes one or exits. +rb_answer() { + local answer tries=0 + answer=$(PortMasterDialogResult "$@") + while [ "$answer" = "WAIT" ] || [ -z "$answer" ]; do + if [ -n "$PM_PID" ] && [ ! -d "/proc/$PM_PID" ]; then + break + fi + if [ "$tries" -ge 6000 ]; then + break + fi + tries=$((tries + 1)) + sleep 0.3 + answer=$(cat "$PM_DONE" 2>/dev/null) + done + printf '%s\n' "$answer" +} + +rb_plural() { + if [ "$1" = "1" ]; then + printf '%s %s\n' "$1" "$2" + else + printf '%s %ss\n' "$1" "$2" + fi +} + +rb_entry() { + PortMasterDialog "register_set_info" "$1" "$2" "title:$3" "description:$4" +} + +# Show a list and print the chosen key. B, where the release reads it, +# answers like the Back or Quit entry ($2). +rb_choose() { + local answer + answer=$(rb_answer "selection_list" "--register=$1" "--want-description" "--want-cancel") + case "$answer" in + CANCEL|FAIL|WAIT|"") printf '%s\n' "$2" ;; + *) printf '%s\n' "$answer" ;; + esac +} + +# A screen of text with one or two ways out: rb_notice TITLE TEXT KEY LABEL [KEY LABEL]. +# B answers like the last one, which leaves. +rb_notice() { + local title="$1" text="$2" + shift 2 + local last="$1" answer + PortMasterDialog "register_clear" "notice" + # The list has no heading of its own: the first entry is the heading. + rb_entry "notice" "info" "$title" "$text" + rb_entry "notice" "$1" "$2" "$text" + if [ $# -ge 4 ]; then + rb_entry "notice" "$3" "$4" "$text" + last="$3" + fi + while true; do + answer=$(rb_choose "notice" "$last") + if [ "$answer" != "info" ]; then + printf '%s\n' "$answer" + return + fi + done +} + +# --- Reading install.py --------------------------------------------------- + +# Run a --check and read its records into RB_* variables. +rb_check() { + mkdir -p "$RB_STATE" + PortMasterDialog "messages_begin" + PortMasterDialog "message" "Reading the BIOS folder and the file list." + rb_py "$RB_INSTALL" --check --events "${RB_PLATFORM_ARGS[@]}" > "$RB_STATE/check.jsonl" 2>> "$RB_LOG" + PortMasterDialog "messages_end" + rb_py "$RB_EVENTS" status "$RB_STATE/check.jsonl" > "$RB_STATE/status.tsv" 2>> "$RB_LOG" + rb_read_status "$RB_STATE/status.tsv" +} + +rb_read_status() { + RB_OS_NAME="this system" + RB_DETECTED="" + RB_PLATFORM="" + RB_PLATFORM_NAME="" + RB_BIOS_DIR="" + RB_ERROR="" + RB_ERROR_TEXT="" + RB_CHECKED="" + RB_END="none" + RB_SYSTEMS=() + RB_COMPLETE=0 + local kind a b c d e f g h + while IFS=$'\t' read -r kind a b c d e f g h; do + case "$kind" in + detect) RB_OS_NAME="$a"; RB_DETECTED="$b" ;; + platform) RB_PLATFORM="$a"; RB_PLATFORM_NAME="$b"; RB_BIOS_DIR="$c" ;; + error) RB_ERROR="$a"; RB_ERROR_TEXT="$b" ;; + check) + RB_CHECKED=1 + RB_FILES="$a"; RB_PRESENT="$b"; RB_MISSING="$c"; RB_WRONG="$d" + RB_NEEDED_SIZE="$e"; RB_DATA_MISSING="$f"; RB_NOT_INSTALLABLE="$g" + ;; + system) + if [ "$h" = "1" ]; then + RB_SYSTEMS+=("$a"$'\t'"$b"$'\t'"$c"$'\t'"$d"$'\t'"$e"$'\t'"$f"$'\t'"$g") + else + RB_COMPLETE=$((RB_COMPLETE + 1)) + fi + ;; + end) RB_END="$a" ;; + esac + done < "$1" +} + +rb_status_text() { + local text="$RB_OS_NAME, $RB_PLATFORM_NAME file list: $RB_PRESENT/$RB_FILES BIOS files, $RB_MISSING missing, $RB_WRONG wrong hash." + if [ "${RB_NOT_INSTALLABLE:-0}" != "0" ]; then + text="$text $RB_NOT_INSTALLABLE not in the collection." + fi + printf '%s\n' "$text" +} + +rb_pending() { + printf '%s\n' $((RB_MISSING + RB_WRONG + RB_DATA_MISSING)) +} + +# --- Screens -------------------------------------------------------------- + +rb_screen_no_python() { + local found + found=$(python3 --version 2>&1 | awk '{print $2}') + local text + case "$RB_PYTHON_PROBLEM" in + old) text="Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's Python runtime exists for aarch64 only, and this device is $(rb_arch)." ;; + runtime_offline) text="Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's $RB_RUNTIME runtime needs a connection to download." ;; + *) text="Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's $RB_RUNTIME runtime could not be installed or run." ;; + esac + rb_notice "Python 3.8 or newer is needed" "$text $RB_PC_ROUTE" "quit" "Quit" > /dev/null +} + +# What the check found when it found nothing to show: no platform, no +# connection, an unreadable list. Prints check or quit. +rb_screen_problem() { + if [ "$RB_END" = "none" ]; then + rb_notice "The installer stopped before checking" "install.py ended without a result. Its messages are in $RB_LOG." "check" "Check again" "quit" "Quit" + elif [ "$RB_ERROR" = "conflict" ]; then + rb_notice "Two systems found" "$RB_ERROR_TEXT, so the BIOS folder is not known. $RB_PC_ROUTE" "check" "Check again" "quit" "Quit" + elif [ "$RB_DETECTED" = "0" ]; then + rb_notice "No BIOS folder found" "install.py knows no BIOS folder on $RB_OS_NAME. $RB_PC_ROUTE" "check" "Check again" "quit" "Quit" + elif [ "$RB_ERROR" = "network" ]; then + rb_notice "No connection" "$RB_OS_NAME, BIOS folder $RB_BIOS_DIR. The list of files to check comes from the internet, and so do the files: without a connection nothing can be checked or downloaded. Connect to Wi-Fi, then check again." "check" "Check again" "quit" "Quit" + elif [ "$RB_ERROR" = "invalid" ]; then + rb_notice "The file list is newer than this port" "$RB_ERROR_TEXT. Update RetroBIOS from PortMaster, or use the installer on a computer. $RB_PC_ROUTE" "check" "Check again" "quit" "Quit" + else + rb_notice "The file list could not be read" "${RB_ERROR_TEXT:-install.py ended with code $RB_END}. Its messages are in $RB_LOG." "check" "Check again" "quit" "Quit" + fi +} + +# The menu after a check. Prints update, choose, check or quit. +rb_screen_menu() { + local status pending + status=$(rb_status_text) + pending=$(rb_pending) + PortMasterDialog "register_clear" "menu" + # The list has no heading of its own: the first entry is the status. + if [ "$pending" -gt 0 ]; then + local data="" + if [ "${RB_DATA_MISSING:-0}" != "0" ]; then + data=" and $RB_DATA_MISSING data files" + fi + rb_entry "menu" "status" "$RB_OS_NAME: $RB_PRESENT/$RB_FILES BIOS files, $RB_MISSING missing, $RB_WRONG wrong hash" "$status BIOS folder: $RB_BIOS_DIR." + rb_entry "menu" "update" "Update everything missing" "$status Downloads $(rb_plural $((RB_MISSING + RB_WRONG)) file)$data, $RB_NEEDED_SIZE, to $RB_BIOS_DIR." + rb_entry "menu" "choose" "Choose systems" "$status Pick the systems to update." + else + rb_entry "menu" "status" "$RB_OS_NAME: all $RB_FILES BIOS files present" "$status Nothing to download. BIOS folder: $RB_BIOS_DIR." + fi + rb_entry "menu" "check" "Check again" "$status Reads the BIOS folder and the file list again." + rb_entry "menu" "quit" "Quit" "$status" + local answer + while true; do + answer=$(rb_choose "menu" "quit") + if [ "$answer" != "status" ]; then + printf '%s\n' "$answer" + return + fi + done +} + +# Systems with files to fetch, toggled with A. Prints the chosen system +# ids, one per line, or nothing for Back. +rb_screen_systems() { + local -A chosen=() + local line id name files present missing wrong size answer count + while true; do + count=0 + for id in "${!chosen[@]}"; do + count=$((count + 1)) + done + PortMasterDialog "register_clear" "systems" + if [ "$count" -gt 0 ]; then + rb_entry "systems" "update" "Update the $count selected" "Downloads what the selected systems lack to $RB_BIOS_DIR." + else + rb_entry "systems" "update" "Update the selected" "Nothing selected yet: pick systems below with A." + fi + rb_entry "systems" "back" "Back" "Back to the menu. $(rb_plural "$RB_COMPLETE" system) complete, not listed." + for line in "${RB_SYSTEMS[@]}"; do + IFS=$'\t' read -r id name files present missing wrong size <<< "$line" + local mark="" + if [ -n "${chosen[$id]}" ]; then + mark=" (selected)" + fi + rb_entry "systems" "sys:$id" "$name$mark" "$present/$files present, $missing missing, $wrong wrong hash, $size to download. A selects or clears it." + done + answer=$(rb_choose "systems" "back") + case "$answer" in + back) return ;; + update) + if [ "$count" -gt 0 ]; then + printf '%s\n' "${!chosen[@]}" | sort + return + fi + ;; + sys:*) + id="${answer#sys:}" + # Quoted literally so unset expands the subscript once: a double-quoted + # "chosen[$id]" is expanded again inside unset. + [[ "$id" =~ ^[A-Za-z0-9._-]+$ ]] || continue + if [ -n "${chosen[$id]}" ]; then + unset 'chosen[$id]' + else + chosen[$id]=1 + fi + ;; + esac + done +} + +# Download with a progress bar fed by the event stream: rb_update [SYSTEM...] +rb_update() { + local args=() system + for system in "$@"; do + args+=(--system "$system") + done + mkdir -p "$RB_STATE" + PortMasterDialog "messages_begin" + PortMasterDialog "message" "Downloading to $RB_BIOS_DIR." + PortMasterDialog "progress" "Starting" 0 1 + local kind a b c d e f + rb_py "$RB_INSTALL" --events "${RB_PLATFORM_ARGS[@]}" "${args[@]}" 2>> "$RB_LOG" \ + | rb_py "$RB_EVENTS" follow "$RB_STATE/summary.tsv" 2>> "$RB_LOG" \ + | while IFS=$'\t' read -r kind a b c d e f; do + case "$kind" in + download) PortMasterDialog "message" "$(rb_plural "$a" file), $b." ;; + file) PortMasterDialog "progress" "$d, $e of $f" "$a" "$b" ;; + progress) PortMasterDialog "progress" "$c of $d" "$a" "$b" ;; + error) PortMasterDialog "message" "$b" ;; + esac + done + PortMasterDialog "progress_clear" + PortMasterDialog "messages_end" +} + +# What the update did. Prints back or quit. +rb_screen_summary() { + local kind a b c d shown=0 failed=0 + local end="none" installed=0 up_to_date=0 pending=0 interrupted=0 error="" + while IFS=$'\t' read -r kind a b c d; do + case "$kind" in + summary) installed="$a"; up_to_date="$b"; pending="$c"; interrupted="$d" ;; + failed) failed=$((failed + 1)) ;; + error) error="$b" ;; + end) end="$a" ;; + esac + done < "$RB_STATE/summary.tsv" + PortMasterDialog "register_clear" "summary" + # The first entry is the outcome, the heading the list does not have. + if [ "$end" = "none" ]; then + rb_entry "summary" "info:result" "The installer stopped" "install.py ended without a result. Its messages are in $RB_LOG." + elif [ -n "$error" ]; then + rb_entry "summary" "info:result" "Nothing downloaded" "$error." + else + rb_entry "summary" "info:result" "Installed: $(rb_plural "$installed" file)" "$up_to_date already up to date, $failed failed, $pending still to fetch." + fi + rb_entry "summary" "back" "Back to the menu" "Checks the BIOS folder again." + rb_entry "summary" "quit" "Quit" "Leaves RetroBIOS." + if [ "$interrupted" = "1" ]; then + rb_entry "summary" "info:stopped" "Stopped before the end" "Update again to carry on. Files already installed are kept." + fi + while IFS=$'\t' read -r kind a b c d; do + case "$kind" in + failed) + shown=$((shown + 1)) + if [ "$shown" -le 50 ]; then + rb_entry "summary" "info:failed:$shown" "Failed: $a" "$b" + fi + ;; + skipped) + rb_entry "summary" "info:skipped:$a" "Not in the collection ($a): $(rb_plural "$b" file)" "Required: $c. The RetroBIOS collection does not hold these files, so they cannot be downloaded." + ;; + esac + done < "$RB_STATE/summary.tsv" + if [ "$shown" -gt 50 ]; then + rb_entry "summary" "info:more" "And $((shown - 50)) more failed" "The full list is in $RB_LOG." + fi + local answer + while true; do + answer=$(rb_choose "summary" "back") + case "$answer" in + back|quit) printf '%s\n' "$answer"; return ;; + esac + done +} + +# --- Flow ----------------------------------------------------------------- + +rb_main() { + if ! rb_select_python; then + PortMasterDialogInit "no-harbour" + rb_screen_no_python + PortMasterDialogExit + rb_release_python + return 1 + fi + PortMasterDialogInit "no-harbour" + RB_PLATFORM_ARGS=() + local action systems chosen + rb_check + while true; do + if [ -z "$RB_CHECKED" ]; then + action=$(rb_screen_problem) + else + # Later runs name the platform the first one found, so a second + # detected platform never changes what the screens describe. + RB_PLATFORM_ARGS=(--platform "$RB_PLATFORM") + action=$(rb_screen_menu) + fi + case "$action" in + update) + rb_update + [ "$(rb_screen_summary)" = "quit" ] && break + rb_check + ;; + choose) + systems=$(rb_screen_systems) + if [ -n "$systems" ]; then + mapfile -t chosen <<< "$systems" + rb_update "${chosen[@]}" + [ "$(rb_screen_summary)" = "quit" ] && break + rb_check + fi + ;; + check) rb_check ;; + *) break ;; + esac + done + PortMasterDialogExit + rb_release_python + return 0 +} diff --git a/scripts/build_port.py b/scripts/build_port.py new file mode 100644 index 00000000..33082be6 --- /dev/null +++ b/scripts/build_port.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Build the PortMaster port in ports/RetroBIOS into dist/RetroBIOS.zip. + +The archive has the layout PortMaster's own release build gives a port +(PortMaster-New tools/build_release.py): the launch script at the top, and +one folder holding port.json, gameinfo.xml, the README as .md, the +licenses and the port's files. install.py is copied from the repository +root, and only when it is the installer the bootstraps pin, so the port +runs the same bytes as the one-line command of the same commit. Members are +written as the packs write theirs: fixed timestamps, sorted names, the +executable bit read from the source, so the same tree gives the same zip. + +Usage: + python scripts/build_port.py + python scripts/build_port.py --output-dir tmp/port +""" + +from __future__ import annotations + +import argparse +import hashlib +import re +import sys +import zipfile +from pathlib import Path + +from artifacts import ArtifactLockBusy, artifact_lock + +REPO_ROOT = Path(__file__).resolve().parent.parent +PORT_SOURCE = REPO_ROOT / "ports" / "RetroBIOS" +PORT_DIR = "retrobios" +PORT_SCRIPT = "RetroBIOS.sh" +PORT_ZIP = "RetroBIOS.zip" +_PIN = re.compile(r'^DEFAULT_INSTALL_SHA256="([0-9a-f]{64})"$', re.MULTILINE) + + +def pinned_installer_sha256(root: Path = REPO_ROOT) -> str: + """The SHA-256 install.sh accepts for install.py.""" + found = _PIN.search((root / "install.sh").read_text(encoding="utf-8")) + if found is None: + raise ValueError("install.sh pins no installer SHA-256") + return found.group(1) + + +def port_members(root: Path = REPO_ROOT) -> list[tuple[str, Path]]: + """(name in the archive, source file) for every member, sorted by name.""" + source = root / "ports" / "RetroBIOS" + members = [ + (PORT_SCRIPT, source / PORT_SCRIPT), + (f"{PORT_DIR}/port.json", source / "port.json"), + (f"{PORT_DIR}/gameinfo.xml", source / "gameinfo.xml"), + (f"{PORT_DIR}/{PORT_DIR}.md", source / "README.md"), + (f"{PORT_DIR}/install.py", root / "install.py"), + (f"{PORT_DIR}/licenses/LICENSE-retrobios.txt", root / "LICENSE"), + (f"{PORT_DIR}/licenses/NOTICE-retrobios.txt", root / "NOTICE"), + ] + members += [ + (f"{PORT_DIR}/{path.relative_to(source / PORT_DIR).as_posix()}", path) + for path in sorted((source / PORT_DIR).rglob("*")) + if path.is_file() and "__pycache__" not in path.parts + ] + names = [name for name, _path in members] + if len(set(names)) != len(names): + raise ValueError("two port members share a name") + return sorted(members) + + +def build_port(output: Path, root: Path = REPO_ROOT) -> Path: + """Write the port archive to *output* and return it.""" + pinned = pinned_installer_sha256(root) + actual = hashlib.sha256((root / "install.py").read_bytes()).hexdigest() + if actual != pinned: + raise ValueError( + f"install.py is {actual[:12]}, install.sh pins {pinned[:12]}: " + "re-pin the bootstraps before building the port" + ) + # The packs' member writer: imported here, as it loads the whole + # pack builder, which a caller reading PORT_ZIP does not need. + from generate_pack import _add_pack_member # noqa: PLC0415 + + output.parent.mkdir(parents=True, exist_ok=True) + scratch = output.with_name(f".{output.name}.tmp") + with zipfile.ZipFile(scratch, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zf: + for name, path in port_members(root): + _add_pack_member(zf, str(path), name) + scratch.replace(output) + return output + + +def main() -> int: + parser = argparse.ArgumentParser( + allow_abbrev=False, description="Build the RetroBIOS PortMaster port" + ) + parser.add_argument("--output-dir", type=Path, default=REPO_ROOT / "dist", + help="where RetroBIOS.zip is written (default: dist/)") + args = parser.parse_args() + try: + with artifact_lock(str(args.output_dir)): + path = build_port(args.output_dir / PORT_ZIP) + except (ArtifactLockBusy, OSError, ValueError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + digest = hashlib.sha256(path.read_bytes()).hexdigest() + print(f"{path} ({path.stat().st_size} bytes, sha256 {digest})") + return 0 + + +if __name__ == "__main__": + from common import run_cli + + run_cli(main) diff --git a/scripts/check_release.py b/scripts/check_release.py index a3711553..018dbc16 100644 --- a/scripts/check_release.py +++ b/scripts/check_release.py @@ -3,7 +3,8 @@ Run on the draft before publishing it, and on the published release at any time. Every pack asset release.json names must be on the release at the sha256 -SHA256SUMS.txt lists for it, the parts of a pack must add up to the download +SHA256SUMS.txt lists for it, as must the handheld port (RetroBIOS.zip) when +the list names it, the parts of a pack must add up to the download size the README prints, and nothing else may be attached. An upload that failed in the loop, or a part re-uploaded from an older build, otherwise publishes a release that `sha256sum --check` rejects for every user. @@ -26,6 +27,7 @@ from dataclasses import dataclass from pathlib import Path sys.path.insert(0, os.path.dirname(__file__)) +from build_port import PORT_ZIP from check_release_assets import digests_of from largefiles import LARGE_FILES_REPO from release_record import RECORD, load_record @@ -91,7 +93,9 @@ def compare_release( findings.append(Finding("extra", name, "attached but neither recorded nor listed")) for name in sorted(set(recorded) - set(sums)): findings.append(Finding("unlisted", name, f"absent from {SUMS}")) - for name in sorted(set(sums) - set(recorded)): + # The handheld port is a release file but no pack: release.json records + # packs, and the checksum list alone vouches for the port. + for name in sorted(set(sums) - set(recorded) - {PORT_ZIP}): findings.append(Finding("unrecorded", name, f"listed in {SUMS}, absent from release.json")) wanted = {**sums, **(local_files or {})} diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 591a4f5a..fb08e885 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -5283,6 +5283,10 @@ ZIP: extract them all into the same folder. | [AmberELEC](https://amberelec.org/) | Linux OS for Anbernic handhelds: RG351P, RG351V, RG351MP, RG353, RG503 and RG552 | [AmberELEC]({rel}) | `/storage/roms/`, which the pack fills with `bios/` and `neogeo/` | | [EmuELEC](https://emuelec.org/) | Linux OS for Amlogic TV boxes, the Odroid Go Advance, GameForce and RK356x devices | [EmuELEC]({rel}) | `/storage/roms/`, which the pack fills with `bios/`, `neogeo/` and `ports/` | +On a handheld that runs PortMaster, the RetroBIOS port checks and fills the +BIOS folder from the device, with gamepad screens: +[on a handheld](wiki/installer.md#on-a-handheld-the-retrobios-port). + ### FPGA | Setup | What it is | Pack | Extract to | diff --git a/tests/test_check_release.py b/tests/test_check_release.py index c4fca2a3..50a305f7 100644 --- a/tests/test_check_release.py +++ b/tests/test_check_release.py @@ -95,6 +95,21 @@ class ReleaseMatchesWhatWasBuilt(unittest.TestCase): self.assertIn(("unrecorded", "Other_BIOS_Pack.zip"), kinds) self.assertIn(("missing", "Other_BIOS_Pack.zip"), kinds) + def test_the_handheld_port_is_vouched_for_by_the_checksum_list(self): + sums = {**SUMS_LIST, "RetroBIOS.zip": A} + assets = _assets() + self.assertEqual( + _kinds(compare_release(RECORD, sums, assets, "v2026.10.10")), + {("missing", "RetroBIOS.zip")}, + ) + assets["RetroBIOS.zip"] = Asset(70, A) + self.assertEqual(compare_release(RECORD, sums, assets, "v2026.10.10"), []) + assets["RetroBIOS.zip"] = Asset(70, B) + self.assertEqual( + _kinds(compare_release(RECORD, sums, assets, "v2026.10.10")), + {("content", "RetroBIOS.zip")}, + ) + def test_parts_must_add_up_to_the_printed_download_size(self): assets = _assets() assets["Big_BIOS_Pack.part2of2.zip"] = Asset(99, B) diff --git a/tests/test_install.py b/tests/test_install.py index f09687c5..67814009 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -12,6 +12,7 @@ import os import shlex import re import shutil +import socket import subprocess import sys import tempfile @@ -4688,6 +4689,17 @@ class HandheldSystemsByTheirOwnFiles(unittest.TestCase): install.main() self.assertEqual(stop.exception.code, 1) self.assertIn("JELOS and Koriki are both detected here", err.getvalue()) + with unittest.mock.patch.object(sys, "argv", ["install.py", "--no-input", "--events"]), \ + unittest.mock.patch.object(install, "detect_os", return_value="linux"), \ + unittest.mock.patch.object(install, "detect_platforms", return_value=found), \ + unittest.mock.patch.object(install, "detect_frontends", return_value=[]), \ + contextlib.redirect_stdout(io.StringIO()) as out, \ + contextlib.redirect_stderr(io.StringIO()), \ + self.assertRaises(SystemExit): + install.main() + errors = [event for event in map(json.loads, out.getvalue().splitlines()) + if event["event"] == "error"] + self.assertEqual([(e["stage"], e["reason"]) for e in errors], [("detect", "conflict")]) def test_the_system_is_announced_by_its_own_name(self): root, found, _name = self._one({self.ELEC_CFG: ""}, 'OS_NAME="JELOS"\n') @@ -4846,5 +4858,178 @@ class RomMFirmwareInstallsUnderItsTemplate(unittest.TestCase, _LoopbackRun): self.assertIn("{platform}/bios", proc.stdout) +class _SyntheticTree: + """A served tree of four BIOS files over loopback, one with no system, + and a BIOS folder holding one of them, another with other bytes, and + neither of the last two.""" + + FILES = { + "bios/A/a.bin": b"alpha" * 20, + "bios/A/b.bin": b"bravo" * 30, + "bios/B/c.bin": b"charlie" * 10, + "bios/X/d.bin": b"delta" * 5, + } + + def serve_tree(self, test: unittest.TestCase) -> dict: + root = Path(_tempdir(test)) + files = [] + for path, data in self.FILES.items(): + (root / path).parent.mkdir(parents=True, exist_ok=True) + (root / path).write_bytes(data) + name = Path(path).name + systems = {"a.bin": ["sys-a"], "b.bin": ["sys-a"], "c.bin": ["sys-b"]}.get(name) + entry = {"dest": name, "repo_path": path, "size": len(data), + "sha1": hashlib.sha1(data).hexdigest(), "cores": None} + if systems: + entry["systems"] = systems + files.append(entry) + manifest = { + "manifest_version": 2, "platform": "retroarch", "display_name": "RetroArch", + "files": files, "system_names": {"sys-a": ["alpha"]}, + "omitted_files": [{"dest": "e.bin", "name": "e.bin", "system": "sys-b", + "required": True, "reason": "not_found", + "systems": ["sys-b"]}], + } + (root / "install").mkdir() + (root / "install" / "retroarch.json").write_text(json.dumps(manifest)) + + class Quiet(http.server.SimpleHTTPRequestHandler): + def log_message(self, *args) -> None: + return + + handler = functools.partial(Quiet, directory=str(root)) + httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) + threading.Thread(target=httpd.serve_forever, daemon=True).start() + test.addCleanup(httpd.server_close) + test.addCleanup(httpd.shutdown) + self.home = Path(_tempdir(test)) + self.bios = self.home / "bios" + self.bios.mkdir() + (self.bios / "a.bin").write_bytes(self.FILES["bios/A/a.bin"]) + (self.bios / "b.bin").write_bytes(b"other bytes") + return { + **os.environ, "HOME": str(self.home), "RETROBIOS_OS": "linux", + "RETROBIOS_BASE_URL": f"http://127.0.0.1:{httpd.server_address[1]}", + } + + +def _events(proc: subprocess.CompletedProcess) -> list[dict]: + """The --events stream, every stdout line a JSON object.""" + return [json.loads(line) for line in proc.stdout.splitlines()] + + +class EventsDescribeTheRun(unittest.TestCase, _SyntheticTree): + """--events: the handheld port reads the run as JSON lines on stdout and + never parses the text, which goes to stderr.""" + + def setUp(self): + self.env = self.serve_tree(self) + + def _run(self, *argv: str) -> tuple[subprocess.CompletedProcess, list[dict]]: + proc = _install_run(self.env, "--platform", "retroarch", "--dest", str(self.bios), + "--events", "--jobs", "1", *argv) + self.assertNotIn("Traceback", proc.stderr) + return proc, _events(proc) + + def _head(self, mode: str, installer: str) -> list[dict]: + return [ + {"event": "start", "schema": install.EVENT_SCHEMA, "installer": installer, + "mode": mode}, + {"event": "detect", "os": "linux", "os_name": install._os_name(), + "platforms": [{"platform": "retroarch", "name": "RetroArch", + "bios_dir": str(self.bios)}]}, + {"event": "file", "platform": "retroarch", "dest": "a.bin", + "systems": ["sys-a"], "size": 100, "state": "present"}, + {"event": "file", "platform": "retroarch", "dest": "b.bin", + "systems": ["sys-a"], "size": 150, "state": "wrong_hash"}, + {"event": "file", "platform": "retroarch", "dest": "c.bin", + "systems": ["sys-b"], "size": 70, "state": "missing"}, + {"event": "file", "platform": "retroarch", "dest": "d.bin", + "systems": [], "size": 25, "state": "missing"}, + {"event": "system", "platform": "retroarch", "system": "other", "name": "other", + "files": 1, "present": 0, "missing": 1, "wrong_hash": 0, "bytes_needed": 25}, + {"event": "system", "platform": "retroarch", "system": "sys-a", "name": "alpha", + "files": 2, "present": 1, "missing": 0, "wrong_hash": 1, "bytes_needed": 150}, + {"event": "system", "platform": "retroarch", "system": "sys-b", "name": "sys-b", + "files": 1, "present": 0, "missing": 1, "wrong_hash": 0, "bytes_needed": 70}, + {"event": "check", "platform": "retroarch", "name": "RetroArch", + "bios_dir": str(self.bios), "files": 4, "present": 1, "missing": 2, + "wrong_hash": 1, "bytes_needed": 245, "data_missing": 0, + "not_installable": 1}, + ] + + SKIPPED = [{"reason": "not_found", "label": "not found", "files": 1, "required": 1}] + + def test_a_check_run(self): + proc, events = self._run("--check") + self.assertEqual(proc.returncode, 1) + installer = events[0]["installer"] + self.assertEqual(events, self._head("check", installer) + [ + {"event": "summary", "mode": "check", "installed": 0, "up_to_date": 1, + "pending": 3, "failed": [], "skipped": self.SKIPPED, "interrupted": False}, + {"event": "end", "code": 1}, + ]) + self.assertIn("2/4 present (1 verified, 1 wrong hash)", proc.stderr) + self.assertFalse((self.bios / "c.bin").exists()) + + def test_an_install_run(self): + proc, events = self._run() + self.assertEqual(proc.returncode, 0, proc.stderr) + installer = events[0]["installer"] + progress = [event for event in events if event["event"] == "progress"] + for event in progress: + self.assertEqual(list(event), ["event", *install.EVENT_KEYS["progress"]]) + done = [{"event": "downloaded", "dest": dest, "ok": True, "reason": "", + "size": size, "files_done": n, "files_total": 3, "bytes_done": total, + "bytes_total": 245} + for n, (dest, size, total) in enumerate( + (("c.bin", 70, 70), ("d.bin", 25, 95), ("b.bin", 150, 245)), 1)] + self.assertEqual([e for e in events if e["event"] != "progress"], + self._head("install", installer) + [ + {"event": "download", "platform": "retroarch", "files": 3, "bytes": 245}, + *done, + {"event": "summary", "mode": "install", "installed": 3, "up_to_date": 1, + "pending": 0, "failed": [], "skipped": self.SKIPPED, "interrupted": False}, + {"event": "end", "code": 0}, + ]) + self.assertEqual((self.bios / "b.bin").read_bytes(), self.FILES["bios/A/b.bin"]) + self.assertIn("Done. 3 files installed", proc.stderr) + + def test_every_event_has_its_schema_keys(self): + _proc, events = self._run() + for event in events: + self.assertEqual(list(event), ["event", *install.EVENT_KEYS[event["event"]]]) + + def test_a_file_list_out_of_reach_is_a_network_error(self): + # A port nothing listens on: the connection is refused at once. + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + closed = probe.getsockname()[1] + env = {**self.env, "RETROBIOS_BASE_URL": f"http://127.0.0.1:{closed}"} + proc = _install_run(env, "--platform", "retroarch", "--dest", str(self.bios), + "--events", "--check") + events = _events(proc) + self.assertEqual([e["event"] for e in events], ["start", "detect", "error", "end"]) + self.assertEqual(events[2]["reason"], "network") + self.assertEqual(events[2]["stage"], "manifest") + self.assertEqual(events[3], {"event": "end", "code": 1}) + + def test_nothing_detected_is_an_empty_platform_list(self): + proc = _install_run(self.env, "--events", "--check") + events = _events(proc) + self.assertEqual(proc.returncode, 1) + self.assertEqual(events[1]["platforms"], []) + self.assertEqual(events[-1], {"event": "end", "code": 1}) + + def test_the_schema_is_documented(self): + page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8") + for kind, keys in install.EVENT_KEYS.items(): + row = next((line for line in page.splitlines() + if line.startswith(f"| `{kind}` |")), None) + self.assertIsNotNone(row, kind) + for key in keys: + self.assertIn(f"`{key}`", row, f"{kind}.{key}") + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_port.py b/tests/test_port.py new file mode 100644 index 00000000..b752d1b0 --- /dev/null +++ b/tests/test_port.py @@ -0,0 +1,469 @@ +"""The PortMaster port: its archive, its event reader and its screens. + +The screens are driven here with PortMasterDialog's functions replaced by +stubs that record each command and answer from a script, and with a fake +install.py that writes canned --events streams. What a device adds (pugwash +drawing, the pad, mount, the network) is not exercised. +""" +from __future__ import annotations + +import hashlib +import importlib.util +import io +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +import zipfile +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +PORT = REPO_ROOT / "ports" / "RetroBIOS" +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +import build_port # noqa: E402 + +_spec = importlib.util.spec_from_file_location("install", REPO_ROOT / "install.py") +install = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(install) +_spec = importlib.util.spec_from_file_location( + "rbevents", PORT / "retrobios" / "rbevents.py") +rbevents = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(rbevents) + +SCRIPTS = [PORT / "RetroBIOS.sh", PORT / "retrobios" / "screens.txt"] + + +def _tempdir(test: unittest.TestCase) -> Path: + scratch = REPO_ROOT / "tmp" + scratch.mkdir(exist_ok=True) + path = Path(tempfile.mkdtemp(dir=str(scratch))) + test.addCleanup(shutil.rmtree, path, True) + return path + + +class EveryScriptParses(unittest.TestCase): + def test_bash_n(self): + for script in SCRIPTS: + with self.subTest(script=script.name): + proc = subprocess.run(["bash", "-n", str(script)], capture_output=True, + text=True, check=False) + self.assertEqual(proc.returncode, 0, proc.stderr) + + def test_only_dialog_functions_the_oldest_releases_have(self): + """PortMaster 2024.02.26 and 8.5.23 define these in PortMasterDialog.txt, + and have no funcs.txt: pm_message and the rest would be undefined.""" + known = {"PortMasterDialogInit", "PortMasterDialog", "PortMasterDialogResult", + "PortMasterDialogExit", "PortMasterIPCheck"} + commands = {"register_set_info", "register_clear", "selection_list", + "messages_begin", "message", "messages_end", "progress", + "progress_clear", "check_runtime"} + text = "\n".join(path.read_text(encoding="utf-8") for path in SCRIPTS) + import re # noqa: PLC0415 + used = set(re.findall(r"\b(PortMasterDialog\w*|PortMasterIPCheck)\b", text)) + self.assertLessEqual(used - {"PortMasterDialog.txt"}, known) + self.assertNotRegex(text, r"\bpm_message\b|\bpm_platform_helper\b") + sent = set(re.findall(r'(?:PortMasterDialog|rb_answer) "(\w+)"', text)) + self.assertLessEqual(sent, commands) + self.assertNotIn("register_clear_info", text) + + +class ThePortArchive(unittest.TestCase): + def setUp(self): + self.out = _tempdir(self) + + def _build(self, name: str = "RetroBIOS.zip") -> Path: + return build_port.build_port(self.out / name) + + def test_layout_portmaster_installs(self): + """One launch script at the top, one folder holding port.json: what + harbourmaster's captain.check_port accepts without a warning.""" + with zipfile.ZipFile(self._build()) as zf: + names = zf.namelist() + port = json.loads(zf.read("retrobios/port.json")) + mode = zf.getinfo("RetroBIOS.sh").external_attr >> 16 + self.assertEqual(names, sorted(names)) + self.assertEqual([n for n in names if "/" not in n], ["RetroBIOS.sh"]) + self.assertEqual({n.split("/")[0] for n in names if "/" in n}, {"retrobios"}) + self.assertEqual([n for n in names if n.endswith(".sh")], ["RetroBIOS.sh"]) + for member in ("retrobios/install.py", "retrobios/rbevents.py", + "retrobios/screens.txt", "retrobios/gameinfo.xml", + "retrobios/retrobios.md", "retrobios/licenses/LICENSE-retrobios.txt"): + self.assertIn(member, names) + self.assertEqual(port["version"], 4) + self.assertEqual(port["name"], "retrobios.zip") + self.assertEqual(port["items"], ["RetroBIOS.sh", "retrobios"]) + self.assertEqual(port["attr"]["genres"], ["other"]) + self.assertTrue(mode & 0o111, "the launch script is executable") + + def test_bundled_installer_is_the_pinned_one(self): + with zipfile.ZipFile(self._build()) as zf: + bundled = hashlib.sha256(zf.read("retrobios/install.py")).hexdigest() + self.assertEqual(bundled, build_port.pinned_installer_sha256()) + install_sh = (REPO_ROOT / "install.sh").read_text(encoding="utf-8") + self.assertIn(f'DEFAULT_INSTALL_SHA256="{bundled}"', install_sh) + + def test_an_unpinned_installer_is_refused(self): + root = _tempdir(self) + for name in ("install.py", "install.sh", "LICENSE", "NOTICE"): + shutil.copy(REPO_ROOT / name, root / name) + shutil.copytree(PORT, root / "ports" / "RetroBIOS") + with (root / "install.py").open("a") as fh: + fh.write("\n") + with self.assertRaises(ValueError): + build_port.build_port(self.out / "x.zip", root) + + def test_reproducible(self): + first = self._build("a.zip").read_bytes() + second = self._build("b.zip").read_bytes() + self.assertEqual(first, second) + + +class EventRecords(unittest.TestCase): + """rbevents turns --events into fields the FIFO carries intact.""" + + def test_fields_are_fifo_safe(self): + self.assertEqual(rbevents.clean("a\tb\n[x]*? c"), "a b x c") + out = io.StringIO() + rbevents.record(out, "file", "", "a") + self.assertEqual(out.getvalue(), "file\t-\ta\n") + + def test_follow_writes_the_outcome(self): + stream = [ + json.dumps({"event": "download", "platform": "p", "files": 1, "bytes": 2048}), + "not json", + json.dumps({"event": "downloaded", "dest": "x.bin", "ok": False, + "reason": "HTTP 404", "size": 2048, "files_done": 1, + "files_total": 1, "bytes_done": 0, "bytes_total": 2048}), + json.dumps({"event": "summary", "mode": "install", "installed": 0, + "up_to_date": 4, "pending": 1, + "failed": [{"dest": "x.bin", "reason": "HTTP 404"}], + "skipped": [], "interrupted": False}), + json.dumps({"event": "end", "code": 2}), + ] + out, summary = io.StringIO(), io.StringIO() + rbevents.follow(stream, out, summary) + self.assertEqual(out.getvalue().splitlines(), [ + "download\t1\t2 KB", "file\t1\t1\tfailed\tx.bin\t0 B\t2 KB"]) + self.assertEqual(summary.getvalue().splitlines(), [ + "summary\t0\t4\t1\t0", "failed\tx.bin\tHTTP 404", "end\t2"]) + + def test_a_system_id_that_is_no_slug_is_dropped(self): + # The id becomes a bash array subscript in the launcher, where a + # command substitution in it would run. + def system(name): + return json.dumps({"event": "system", "platform": "p", "system": name, + "name": "N", "files": 1, "present": 0, "missing": 1, + "wrong_hash": 0, "bytes_needed": 1}) + stream = [ + json.dumps({"event": "detect", "os": "linux", "os_name": "X", + "platforms": [{"platform": "p", "name": "P", "bios_dir": "/b"}]}), + system("$(touch pwned)"), system("a`id`"), system("sony-playstation"), + ] + out = io.StringIO() + rbevents.status(stream, out) + ids = [line.split("\t")[1] for line in out.getvalue().splitlines() + if line.startswith("system\t")] + self.assertEqual(ids, ["sony-playstation"]) + + def test_launcher_never_double_expands_a_subscript(self): + text = (PORT / "retrobios" / "screens.txt").read_text() + self.assertNotRegex(text, r'unset\s+"[^"]*\[\$') + + def test_a_run_without_end_says_so(self): + out = io.StringIO() + rbevents.status([], out) + self.assertEqual(out.getvalue(), "end\tnone\n") + + +FAKE_INSTALL = r''' +import json, os, sys +from pathlib import Path + +state = Path(os.environ["FAKE_STATE"]) +scenario = os.environ["FAKE_SCENARIO"] +keys = json.loads(os.environ["FAKE_KEYS"]) +with (state / "calls.txt").open("a") as fh: + fh.write(" ".join(sys.argv[1:]) + "\n") +check = "--check" in sys.argv + + +def emit(kind, **fields): + assert sorted(fields) == sorted(keys[kind]), kind + print(json.dumps({"event": kind, **fields}), flush=True) + + +def end(code): + emit("end", code=code) + sys.exit(code) + + +emit("start", schema=1, installer="fake", mode="check" if check else "install") +if scenario == "nodetect": + emit("detect", os="linux", os_name="Fake OS 1.0", platforms=[]) + end(1) +if scenario == "conflict": + emit("error", stage="detect", reason="conflict", platform="", + message="JELOS and Koriki are both detected here, and a machine runs one") + end(1) +emit("detect", os="linux", os_name="Fake OS 1.0", platforms=[ + {"platform": "retroarch", "name": "RetroArch", "bios_dir": "/roms/bios"}]) +if scenario == "offline": + emit("error", stage="manifest", reason="network", platform="retroarch", + message="the file list could not be fetched: [Errno 101] Network is unreachable") + end(1) +done = scenario == "complete" or (state / "installed").exists() +if check: + for system, name, files, present, missing, wrong, needed in ( + ("sys-a", "Alpha*", 2, 2 if done else 0, 0 if done else 1, 0 if done else 1, + 0 if done else 300), + ("sys-b", "Bravo", 1, 1, 0, 0, 0), + ): + emit("system", platform="retroarch", system=system, name=name, files=files, + present=present, missing=missing, wrong_hash=wrong, bytes_needed=needed) + emit("check", platform="retroarch", name="RetroArch", bios_dir="/roms/bios", files=3, + present=3 if done else 1, missing=0 if done else 1, wrong_hash=0 if done else 1, + bytes_needed=0 if done else 300, data_missing=0, not_installable=1) + emit("summary", mode="check", installed=0, up_to_date=3 if done else 1, + pending=0 if done else 2, failed=[], skipped=[], interrupted=False) + end(0 if done else 1) +emit("download", platform="retroarch", files=2, bytes=300) +for n, (dest, size) in enumerate((("a.bin", 100), ("b.bin", 200)), 1): + emit("downloaded", dest=dest, ok=True, reason="", size=size, files_done=n, + files_total=2, bytes_done=100 if n == 1 else 300, bytes_total=300) +(state / "installed").write_text("") +emit("summary", mode="install", installed=2, up_to_date=1, pending=0, failed=[], + skipped=[{"reason": "not_found", "label": "not found", "files": 1, "required": 1}], + interrupted=False) +end(0) +''' + +# PortMasterDialog.txt replaced: every call is a trace line, fields split by +# \x1f, and each selection list is answered from the answers file. +DRIVER = r''' +trace() { + printf '%s' "$1" + shift + local field + for field in "$@"; do printf '\x1f%s' "$field"; done + printf '\n' +} >> "$RB_TRACE" +PM_PID="" +PM_DONE="$FAKE_STATE/pm_done" +PortMasterDialogInit() { trace INIT "$@"; } +PortMasterDialogExit() { trace EXIT; } +PortMasterIPCheck() { [ -n "$FAKE_IP" ] && echo 192.168.0.1; } +PortMasterDialog() { trace CMD "$@"; } +PortMasterDialogResult() { + trace ASK "$@" + if [ "$1" = "check_runtime" ]; then + echo "${FAKE_RUNTIME:-FAIL}" + return + fi + local answer + answer=$(head -n 1 "$RB_ANSWERS") + sed -i 1d "$RB_ANSWERS" + case "$answer" in + "") echo CANCEL ;; + WAIT:*) printf '%s' "${answer#WAIT:}" > "$PM_DONE"; echo WAIT ;; + *) echo "$answer" ;; + esac +} +source "$GAMEDIR/screens.txt" +if [ -n "$FAKE_ARCH" ]; then + rb_arch() { echo "$FAKE_ARCH"; } +fi +rb_main +trace STATUS "$?" +''' + + +class TheScreens(unittest.TestCase): + """Which screens the port shows, scenario by scenario.""" + + def run_port(self, scenario: str, answers: list[str], **env: str) -> list[tuple]: + work = _tempdir(self) + (work / "install.py").write_text(FAKE_INSTALL) + (work / "driver.sh").write_text(DRIVER) + (work / "answers").write_text("".join(f"{a}\n" for a in answers)) + bin_dir = work / "bin" + bin_dir.mkdir() + # The oldest Python install.py supports, where it is installed: the + # event reader runs on whatever the device has. + (bin_dir / "python3").symlink_to(shutil.which("python3.8") or sys.executable) + self.work = work + full_env = { + **os.environ, + "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", + "HOME": str(work), + "GAMEDIR": str(PORT / "retrobios"), + "controlfolder": str(work / "PortMaster"), + "ESUDO": "", + "RB_STATE": str(work / "run"), + "RB_LOG": str(work / "log.txt"), + "RB_INSTALL": str(work / "install.py"), + "RB_TRACE": str(work / "trace"), + "RB_ANSWERS": str(work / "answers"), + "FAKE_STATE": str(work), + "FAKE_SCENARIO": scenario, + "FAKE_KEYS": json.dumps(install.EVENT_KEYS), + **env, + } + proc = subprocess.run(["bash", str(work / "driver.sh")], env=full_env, + capture_output=True, text=True, timeout=120, check=False) + self.assertEqual(proc.returncode, 0, proc.stderr) + log = work / "log.txt" + self.assertNotIn("Traceback", log.read_text() if log.exists() else "") + return self._screens((work / "trace").read_text(encoding="utf-8")) + + def _screens(self, trace: str) -> list[tuple]: + """INIT, EXIT, STATUS, each list shown with its entries, and the + progress and message commands, in order.""" + registers: dict[str, dict[str, tuple[str, str]]] = {} + shown: list[tuple] = [] + for line in trace.splitlines(): + kind, *fields = line.split("\x1f") + for field in fields: + self.assertNotRegex(field, r"[*?\[\]\t]", line) + if kind == "CMD" and fields[0] == "register_clear": + registers[fields[1]] = {} + elif kind == "CMD" and fields[0] == "register_set_info": + info = dict(item.split(":", 1) for item in fields[3:]) + registers.setdefault(fields[1], {})[fields[2]] = ( + info["title"], info["description"]) + elif kind == "ASK" and fields[0] == "selection_list": + self.assertIn("--want-cancel", fields) + name = fields[1].split("=", 1)[1] + shown.append(("list", name, dict(registers[name]))) + elif kind == "ASK": + shown.append(("ask", *fields)) + elif kind == "CMD" and fields[0] in ("progress", "message"): + shown.append(("cmd", *fields)) + elif kind in ("INIT", "EXIT", "STATUS"): + shown.append((kind, *fields)) + for screen in shown: + if screen[0] == "list": + titles = [title for title, _desc in screen[2].values()] + self.assertTrue({"Back", "Quit", "Back to the menu"} & set(titles), + f"{screen[1]} has no Back or Quit entry") + return shown + + def lists(self, shown: list[tuple]) -> list[tuple[str, list[str]]]: + return [(s[1], list(s[2])) for s in shown if s[0] == "list"] + + def calls(self) -> list[str]: + return (self.work / "calls.txt").read_text().splitlines() + + def test_all_present(self): + shown = self.run_port("complete", ["quit"]) + self.assertEqual(self.lists(shown), [("menu", ["status", "check", "quit"])]) + menu = shown[[s[0] for s in shown].index("list")][2] + self.assertEqual(menu["status"][0], "Fake OS 1.0: all 3 BIOS files present") + self.assertEqual(shown[0], ("INIT", "no-harbour")) + self.assertEqual(shown[-2:], [("EXIT",), ("STATUS", "0")]) + self.assertEqual(self.calls(), ["--check --events"]) + + def test_some_missing_update_everything(self): + shown = self.run_port("missing", ["update", "back", "quit"]) + self.assertEqual(self.lists(shown), [ + ("menu", ["status", "update", "choose", "check", "quit"]), + ("summary", ["info:result", "back", "quit", "info:skipped:not found"]), + ("menu", ["status", "check", "quit"]), + ]) + menu = shown[[s[0] for s in shown].index("list")][2] + self.assertEqual(menu["status"][0], "Fake OS 1.0: 1/3 BIOS files, 1 missing, 1 wrong hash") + self.assertIn("Downloads 2 files, 300 B, to /roms/bios", menu["update"][1]) + progress = [s for s in shown if s[:2] == ("cmd", "progress")] + self.assertEqual(progress, [ + ("cmd", "progress", "Starting", "0", "1"), + ("cmd", "progress", "a.bin, 100 B of 300 B", "1", "2"), + ("cmd", "progress", "b.bin, 300 B of 300 B", "2", "2"), + ]) + summary = [s for s in shown if s[:2] == ("list", "summary")][0][2] + self.assertEqual(summary["info:result"][0], "Installed: 2 files") + self.assertEqual(self.calls(), [ + "--check --events", + "--events --platform retroarch", + "--check --events --platform retroarch", + ]) + + def test_some_missing_choose_systems(self): + shown = self.run_port("missing", [ + "choose", "update", "sys:sys-a", "sys:sys-a", "sys:sys-a", "update", "quit"]) + systems = [s[2] for s in shown if s[:2] == ("list", "systems")] + self.assertEqual(list(systems[0]), ["update", "back", "sys:sys-a"]) + self.assertEqual(systems[0]["sys:sys-a"][0], "Alpha") + self.assertEqual(systems[1]["sys:sys-a"][0], "Alpha") + self.assertEqual(systems[2]["sys:sys-a"][0], "Alpha (selected)") + self.assertEqual(systems[2]["update"][0], "Update the 1 selected") + self.assertIn("1 system complete, not listed", systems[0]["back"][1]) + self.assertEqual(self.calls()[1], "--events --platform retroarch --system sys-a") + self.assertEqual(self.lists(shown)[-1][0], "summary") + self.assertEqual(shown[-1], ("STATUS", "0")) + + def test_no_network(self): + shown = self.run_port("offline", ["quit"]) + self.assertEqual(self.lists(shown), [("notice", ["info", "check", "quit"])]) + notice = shown[[s[0] for s in shown].index("list")][2] + self.assertEqual(notice["info"][0], "No connection") + self.assertIn("Connect to Wi-Fi", notice["info"][1]) + self.assertIn("/roms/bios", notice["info"][1]) + + def test_detection_failure(self): + shown = self.run_port("nodetect", ["check", "quit"]) + self.assertEqual([name for name, _keys in self.lists(shown)], ["notice", "notice"]) + notice = shown[[s[0] for s in shown].index("list")][2] + self.assertEqual(notice["info"][0], "No BIOS folder found") + self.assertIn("Fake OS 1.0", notice["info"][1]) + self.assertIn("--dest", notice["info"][1]) + self.assertEqual(len(self.calls()), 2) + + def test_two_systems_found(self): + shown = self.run_port("conflict", ["check", "quit"]) + notice = shown[[s[0] for s in shown].index("list")][2] + self.assertEqual(notice["info"][0], "Two systems found") + self.assertIn("JELOS and Koriki", notice["info"][1]) + self.assertIn("--dest", notice["info"][1]) + + def test_python_too_old(self): + for arch, env, words in ( + ("armv7l", {}, ["3.7.3", "aarch64 only", "armv7l", "--dest"]), + ("aarch64", {}, ["3.7.3", "needs a connection", "--dest"]), + ("aarch64", {"FAKE_IP": "1", "FAKE_RUNTIME": "FAIL"}, + ["3.7.3", "could not be installed", "--dest"]), + ): + with self.subTest(arch=arch, env=env): + shown = self.run_old(arch, **env) + lists = self.lists(shown) + self.assertEqual(lists, [("notice", ["info", "quit"])]) + notice = [s for s in shown if s[0] == "list"][0][2] + self.assertEqual(notice["info"][0], "Python 3.8 or newer is needed") + for word in words: + self.assertIn(word, notice["info"][1]) + self.assertEqual(shown[-1], ("STATUS", "1")) + self.assertFalse((self.work / "calls.txt").exists()) + asked_runtime = ("ask", "check_runtime", "python_3.11.squashfs") in shown + self.assertEqual(asked_runtime, bool(env.get("FAKE_RUNTIME"))) + + def run_old(self, arch: str, **env: str) -> list[tuple]: + """A python3 on PATH that is 3.7.3, ahead of every other.""" + holder = _tempdir(self) + fake = holder / "python3" + fake.write_text('#!/bin/sh\n[ "$1" = "--version" ] && echo "Python 3.7.3"\nexit 1\n') + fake.chmod(0o755) + return self.run_port("complete", [], FAKE_ARCH=arch, + PATH=f"{holder}{os.pathsep}{os.environ['PATH']}", **env) + + def test_an_answer_after_the_old_30_second_wait(self): + """2024.02.26 and 8.5.23 stop waiting after 30 s and answer WAIT: + the port reads the answer pugwash writes later.""" + shown = self.run_port("complete", ["WAIT:quit"]) + self.assertEqual(shown[-1], ("STATUS", "0")) + self.assertEqual(len(self.lists(shown)), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/wiki/installer.md b/wiki/installer.md index ff60e671..06a32891 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -251,6 +251,7 @@ what the options name, everything by default. | `--dry-run` | Show what would be downloaded, by system and with sizes, without writing anything | | `--yes`, `-y` | Do not ask before a download of 1 GB or more | | `--quiet`, `-q` | Print only failures and the final summary. Implies `--no-input` | +| `--events` | Write the run as JSON events on standard output, one object per line, and the usual text on standard error. Implies `--no-input`. See [Machine-readable events](#machine-readable-events) | | `--version` | Print the installer's identity, the first 12 hex digits of the SHA-256 the bootstraps pin | | `--list-platforms` | Print the supported platforms and what was detected here | | `--list-targets` | Print the hardware targets a platform publishes, with the core count each carries | @@ -267,6 +268,32 @@ it, a file already present with the expected hash is left untouched, and one whose contents do not match is re-downloaded and replaced by the verified copy. Files the list does not name are never read, moved or deleted. +## Machine-readable events + +With `--events`, standard output carries only JSON objects, one per line, and +the text a person reads goes to standard error. A program driving the installer +(the [handheld port](#on-a-handheld-the-retrobios-port) is one) reads the +objects and never parses the text. Every object has an `event` key naming its +kind, and each kind has exactly the keys below, in this order. The schema is +`EVENT_KEYS` in `install.py`, and a test holds this table to it. + +| Kind | Keys | When | +|------|------|------| +| `start` | `schema`, `installer`, `mode` | First line. `schema` is 1, `installer` the identity `--version` prints, `mode` one of `install`, `check`, `dry-run`, `list` | +| `detect` | `os`, `os_name`, `platforms` | Once the platforms are known. `os_name` comes from `/etc/os-release`. `platforms` lists `{platform, name, bios_dir}`, and is empty when nothing supported was found | +| `error` | `stage`, `reason`, `platform`, `message` | The file list could not be read (`stage` is `manifest`). `reason` is `network`, `not_found` or `invalid` | +| `file` | `platform`, `dest`, `systems`, `size`, `state` | Each file of the list after the check. `state` is `present` (hash verified), `wrong_hash` or `missing` | +| `system` | `platform`, `system`, `name`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed` | Each system, sorted by id. Files with no system count under `other`. `name` is the platform's own name for it | +| `check` | `platform`, `name`, `bios_dir`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed`, `data_missing`, `not_installable` | The platform's totals. `data_missing` counts data-tree files to fetch, `not_installable` the files the collection cannot supply | +| `download` | `platform`, `files`, `bytes` | Before the downloads start | +| `progress` | `files_done`, `files_total`, `bytes_done`, `bytes_total` | At most every half second while bytes arrive | +| `downloaded` | `dest`, `ok`, `reason`, `size`, `files_done`, `files_total`, `bytes_done`, `bytes_total` | Each finished file. `reason` says why one failed, and is empty otherwise | +| `summary` | `mode`, `installed`, `up_to_date`, `pending`, `failed`, `skipped`, `interrupted` | The outcome. `pending` counts what a next run would still fetch, `failed` lists `{dest, reason}`, `skipped` lists `{reason, label, files, required}` for the files the collection cannot supply. Not written for a listing | +| `end` | `code` | Last line, with the exit status the process returns (see [Exit status](#exit-status)) | + +An option refused before the run starts (`install.py: error:`, code 2) writes +no event at all. A program reads a missing `end` as that case. + ## Environment overrides | Variable | Effect | @@ -560,6 +587,67 @@ elsewhere. A custom BIOS directory is still named directly: python install.py --platform retroarch --dest /storage/emulated/0/RetroArch/system ``` +## On a handheld: the RetroBIOS port + +On a Linux handheld that runs [PortMaster](https://portmaster.games/) (ArkOS, +ROCKNIX, AmberELEC, muOS, KNULLI and the others it supports), the installer +also runs from the device itself, as a port with gamepad screens. The screens +are PortMaster's own, so the port carries no graphics code. + +**Getting it.** Two ways: + +- From PortMaster's catalog, once the port is accepted there: open PortMaster, + find RetroBIOS under the Other genre, install. +- `RetroBIOS.zip`, attached to each [release](https://github.com/Abdess/retrobios/releases/latest) + cut since the port was added, copied into PortMaster's + autoinstall folder. Starting PortMaster then installs it, with or without a + network connection. PortMaster's FAQ names the folder: `/roms/ports/PortMaster/autoinstall/` + on AmberELEC and ROCKNIX, `/roms/tools/PortMaster/autoinstall/` on ArkOS, + `/mmc/MUOS/PortMaster/autoinstall/` on muOS, + `/userdata/system/.local/share/PortMaster/autoinstall/` on KNULLI. + +From a clone of the repository, `python scripts/build_port.py` builds the +same archive into `dist/RetroBIOS.zip`. + +**What it shows.** RetroBIOS appears among the ports. Started, it runs +`install.py --check --events` and opens on the result: the system's name, +how many BIOS files are present, missing, or present with a hash that does +not match. The menu +offers to update everything missing, to choose systems (each listed with what +it lacks, selected or cleared with A), to check again, or to quit. An update +shows a progress bar, one step per file, then what was installed, what failed +and why, and what the collection cannot supply. Every list has its own Back or +Quit entry, since older PortMaster releases do not read B there. + +**What it needs.** + +- A network connection: the list of files to check comes from the internet, + as do the files. Without one the port says so and offers to check again. +- Python 3.8 or newer. The port uses the system's `python3` when it is recent + enough, else PortMaster's `python_3.11` runtime on aarch64 devices, which it + downloads through PortMaster on first use. An armhf device with an older + Python is told so, with the computer route below. +- A BIOS folder the installer detects (see [Platform detection](#platform-detection)). + On a system it does not know yet, the port says it found no BIOS folder. + +**The computer route.** With the SD card mounted on a computer, the one-line +command fills it, naming the platform and the card's BIOS folder: +`--platform --dest `. The port's screens +for an unknown system or an old Python name this route. + +**Which installer runs.** The archive carries `install.py`, the file the +one-line command of the same commit downloads: the build refuses any other +(`scripts/build_port.py` compares it with the SHA-256 `install.sh` pins). A +port that downloaded the installer at each start against a pinned SHA-256 +would stop working at the next change to `install.py`, since the pin is fixed +when the port is built. The file lists come from `main`, as for the one-line +command. A newer port version carries a newer installer. When a list is in a +format the carried installer does not read, the port says to update it. + +The launcher (`ports/RetroBIOS/RetroBIOS.sh`) and its screens +(`retrobios/screens.txt`) read the installer through +[Machine-readable events](#machine-readable-events) only. + ## Where the files come from Each manifest entry names either `repo_path`, served from the repository at the diff --git a/wiki/release-process.md b/wiki/release-process.md index 864fb59c..d0a8bcd8 100644 --- a/wiki/release-process.md +++ b/wiki/release-process.md @@ -202,6 +202,11 @@ python scripts/pipeline.py --include-archived # and the set compared with the pack before the pack is removed. python scripts/split_pack.py dist/ +# 2b. The handheld port, RetroBIOS.zip, built from ports/RetroBIOS with the +# installer the bootstraps pin. It is a release file, listed in the +# checksums below, and check_release.py holds it to them. +python scripts/build_port.py + # 3. Checksums of the files as published, then sign the list. The checksums # answer corruption, and the signature answers a rewritten release, which is # the one thing a checksum published beside its own artifacts cannot diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index cc440238..61aeecdf 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -44,6 +44,7 @@ Every module in `tests/`, by the area it guards. | Slots and regions | synthetic + profiles | `test_region`, `test_region_agnostic_scan`, `test_region_groups`, `test_region_held`, `test_region_platform_index`, `test_region_required_only`, `test_slot`, `test_slots`, `test_slots_shipped_file` | | Hardware targets and systems | synthetic + target files | `test_extract_targets`, `test_lakka_targets`, `test_libretro_slugs`, `test_system_ids`, `test_system_selection`, `test_target_names`, `test_target_scrapers`, `test_target_systems`, `test_target_verify` | | Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_selection` | +| Handheld port | stubbed PortMaster dialogs, fake installer | `test_port` | | Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` | | Scrapers, upstream and provenance | inline sources, synthetic | `test_check_freshness`, `test_dedup`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_profile_sync`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_scraper_contract`, `test_upstream`, `test_upstream_index` | | Site, docs and repository contracts | rendered pages, sources | `test_contributing_text`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_site_availability`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_platform_hashes`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_write`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | @@ -251,6 +252,23 @@ python scripts/generate_pack.py --all --verify-packs --output-dir dist/ Integrated as pipeline step 6/8 (runs after consistency check, before README generation). Requires packs in `dist/`, skip with `--skip-packs`. +### test_port.py + +The PortMaster port in `ports/RetroBIOS/`. `bash -n` on its scripts, the +archive `scripts/build_port.py` writes (layout, reproducibility, the bundled +`install.py` equal to the one `install.sh` pins), and the event reader. The +screens run in bash with PortMasterDialog's functions replaced by stubs that +record each command and answer from a script, against a fake `install.py` +whose event streams follow `EVENT_KEYS`: all files present, some missing +(update everything, choose systems), no network, no BIOS folder detected, +Python too old, and an answer that arrives after the 30-second wait of the +older PortMaster releases. Drawing, the pad, `mount` and the network belong +to a device and are not exercised. + +```bash +python -m unittest tests.test_port -v +``` + ## Verification discipline The test suite is one layer of verification. The full quality gate is: