From 65362e628fcba7902ad37129ac129f95bd3b4fb9 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:57:59 +0200 Subject: [PATCH 1/5] feat: add --events output to the installer One JSON object per line on stdout (detection, per-file and per-system check, download progress, summary, exit code) and the usual text on stderr, so a program driving install.py never parses prose. EVENT_KEYS is the one schema. The installer page documents it, and a test holds the two equal. The handheld port reads it. --- install.ps1 | 2 +- install.py | 220 +++++++++++++++++++++++++++++++++++++++++- install.sh | 2 +- tests/test_install.py | 174 +++++++++++++++++++++++++++++++++ wiki/installer.md | 26 +++++ 5 files changed, 420 insertions(+), 4 deletions(-) diff --git a/install.ps1 b/install.ps1 index 16e613e2..6dcd7a1a 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "fe8de4eda0919c268d4d2c0d571c7d0dc1cf675d7604263766f5f99a35aa3724" + $defaultInstallSha256 = "3bdaabc9f88e20e09afb99805df5481527611bac796906b98ae6579c3dfcd4a4" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 9cd3e52e..517feaff 100644 --- a/install.py +++ b/install.py @@ -342,6 +342,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 @@ -349,6 +350,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: @@ -360,6 +362,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: @@ -429,6 +435,141 @@ 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. + "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", @@ -2164,9 +2305,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): @@ -2190,6 +2337,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): @@ -3612,6 +3762,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): @@ -4733,6 +4889,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", @@ -4763,6 +4925,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 @@ -4948,6 +5133,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. @@ -4970,6 +5156,12 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: for name, path in platforms: print(f" Found {display_name(name)} at {path}") + 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) @@ -4986,6 +5178,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] = [] @@ -5253,6 +5447,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: @@ -5285,8 +5488,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") @@ -5294,6 +5496,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 ]) @@ -5313,7 +5516,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.") @@ -5341,6 +5546,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() @@ -5407,6 +5615,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 6f563d44..2cd908b2 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="fe8de4eda0919c268d4d2c0d571c7d0dc1cf675d7604263766f5f99a35aa3724" +DEFAULT_INSTALL_SHA256="3bdaabc9f88e20e09afb99805df5481527611bac796906b98ae6579c3dfcd4a4" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/test_install.py b/tests/test_install.py index c16ca60e..08376eb2 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 @@ -4379,5 +4380,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/wiki/installer.md b/wiki/installer.md index 57dae073..83409744 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,31 @@ 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 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 | From ecadb3cb32e04e024907ca8254b5e27ff80ebc78 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:58:42 +0200 Subject: [PATCH 2/5] feat: add the RetroBIOS PortMaster port ports/RetroBIOS runs install.py on a handheld with PortMaster's own screens (pugwash fifo_control through PortMasterDialog.txt), using only what PortMaster 2024.02.26 and the 8.5.23 copy JELOS ships. It reads the installer through --events. scripts/build_port.py writes RetroBIOS.zip in the layout PortMaster's release build gives a port, with the install.py the bootstraps pin, reproducibly. tests/test_port drives the screens with stubbed dialog functions and a fake installer. --- .github/workflows/validate.yml | 3 + ports/RetroBIOS/README.md | 18 + ports/RetroBIOS/RetroBIOS.sh | 44 +++ ports/RetroBIOS/gameinfo.xml | 11 + ports/RetroBIOS/port.json | 31 ++ ports/RetroBIOS/retrobios/rbevents.py | 156 +++++++++ ports/RetroBIOS/retrobios/screens.txt | 462 ++++++++++++++++++++++++++ scripts/build_port.py | 111 +++++++ tests/test_port.py | 436 ++++++++++++++++++++++++ wiki/testing-guide.md | 18 + 10 files changed, 1290 insertions(+) create mode 100644 ports/RetroBIOS/README.md create mode 100755 ports/RetroBIOS/RetroBIOS.sh create mode 100644 ports/RetroBIOS/gameinfo.xml create mode 100644 ports/RetroBIOS/port.json create mode 100644 ports/RetroBIOS/retrobios/rbevents.py create mode 100644 ports/RetroBIOS/retrobios/screens.txt create mode 100644 scripts/build_port.py create mode 100644 tests/test_port.py 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/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..8c71c3a0 --- /dev/null +++ b/ports/RetroBIOS/retrobios/rbevents.py @@ -0,0 +1,156 @@ +#!/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,}") + + +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: + 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..91d45650 --- /dev/null +++ b/ports/RetroBIOS/retrobios/screens.txt @@ -0,0 +1,462 @@ +#!/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_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: checking only" "$RB_OS_NAME, BIOS folder $RB_BIOS_DIR. The list of files to check comes from the internet, and so do the files. 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:}" + 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/tests/test_port.py b/tests/test_port.py new file mode 100644 index 00000000..076a46be --- /dev/null +++ b/tests/test_port.py @@ -0,0 +1,436 @@ +"""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_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) +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: checking only") + 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_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/testing-guide.md b/wiki/testing-guide.md index b3a50f9c..3feaf147 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_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: From 87c81e9694e560e3e98b4b41eea8251e7e91dd88 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:58:45 +0200 Subject: [PATCH 3/5] feat: ship the handheld port with each release build_port.py runs after split_pack.py, so RetroBIOS.zip is in the signed checksum list and uploaded with the packs. check_release.py takes the port from that list: it is no pack, so release.json does not record it. --- scripts/check_release.py | 8 ++++++-- tests/test_check_release.py | 15 +++++++++++++++ wiki/release-process.md | 5 +++++ 3 files changed, 26 insertions(+), 2 deletions(-) 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/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/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 From 7d87828b988b959ca415b4cfa5e1b7dbfbde932a Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:59:12 +0200 Subject: [PATCH 4/5] docs: explain the handheld port The installer page says how to get the port (PortMaster's catalog once accepted, or RetroBIOS.zip in the autoinstall folder), what it shows and needs, and why it carries install.py. The download page links to it from the handheld table. --- scripts/generate_site.py | 4 +++ wiki/installer.md | 66 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 01b379ce..dd87d048 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -5249,6 +5249,10 @@ ZIP: extract them all into the same folder. | [ROCKNIX](https://rocknix.org/) | Linux OS for ARM and x86 handhelds, among them the RG35XX (H700) and the RG552 (RK3399) | [ROCKNIX]({rel}) | `/storage/roms/bios/`, over SSH or the network share | | [Batocera](https://batocera.org/) | Also has images for handheld chips, among them H700 and RK3326 | [Batocera]({rel}) | `/userdata/bios/` | +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/wiki/installer.md b/wiki/installer.md index 83409744..72dd4eb4 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -271,8 +271,9 @@ 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 reads -the objects and never parses the text. Every object has an `event` key naming its +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. @@ -511,6 +512,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 From 8a7e5aa829be0731d8a462f905cd17a106f1400d Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:59:56 +0200 Subject: [PATCH 5/5] fix: name the offline screen for what it does install.py reads the file list before it checks a single file, so without a connection nothing is checked: the screen says No connection and offers to check again. --- ports/RetroBIOS/retrobios/screens.txt | 2 +- tests/test_port.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ports/RetroBIOS/retrobios/screens.txt b/ports/RetroBIOS/retrobios/screens.txt index 91d45650..91d70150 100644 --- a/ports/RetroBIOS/retrobios/screens.txt +++ b/ports/RetroBIOS/retrobios/screens.txt @@ -254,7 +254,7 @@ rb_screen_problem() { 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: checking only" "$RB_OS_NAME, BIOS folder $RB_BIOS_DIR. The list of files to check comes from the internet, and so do the files. Connect to Wi-Fi, then check again." "check" "Check again" "quit" "Quit" + 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 diff --git a/tests/test_port.py b/tests/test_port.py index 076a46be..a6fe051a 100644 --- a/tests/test_port.py +++ b/tests/test_port.py @@ -382,7 +382,7 @@ class TheScreens(unittest.TestCase): 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: checking only") + self.assertEqual(notice["info"][0], "No connection") self.assertIn("Connect to Wi-Fi", notice["info"][1]) self.assertIn("/roms/bios", notice["info"][1])