From d7bd89d42b8121a91100eddee12091183e4bf16e Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:50:29 +0100 Subject: [PATCH] feat: serve the handheld launchers from the site build_port's four archives carry install.py pinned by SHA-256, so they change with the installer, not with the packs. The site now builds them from its own commit and serves them under get/handheld/. Each get/ page of Batocera, KNULLI, ROCKNIX and AmberELEC names its file, size, hash, folder and menu, and get/linux-handhelds/ does the same for muOS, ArkOS, dArkOS and JELOS. The installer's handheld section opens on a download table, both tables written from sitehandheld.LAUNCHERS. --- .github/workflows/deploy-site.yml | 5 + scripts/build_port.py | 15 +- scripts/generate_installer_doc.py | 8 +- scripts/generate_pack.py | 2 +- scripts/generate_site.py | 15 ++ scripts/sitehandheld.py | 258 ++++++++++++++++++++++++++++++ tests/test_site_handheld.py | 168 +++++++++++++++++++ wiki/installer.md | 42 +++-- wiki/testing-guide.md | 2 +- 9 files changed, 494 insertions(+), 21 deletions(-) create mode 100644 scripts/sitehandheld.py create mode 100644 tests/test_site_handheld.py diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 6b442affd..6ce8a3094 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -15,6 +15,7 @@ on: - "scripts/archive_build.py" - "scripts/artifacts.py" - "scripts/auto_fetch.py" + - "scripts/build_port.py" - "scripts/common.py" - "scripts/cross_reference.py" - "scripts/crypto_verify.py" @@ -52,6 +53,7 @@ on: - "scripts/siteagents.py" - "scripts/site_markdown.py" - "scripts/sitebootstrap.py" + - "scripts/sitehandheld.py" - "scripts/sitecollection.py" - "scripts/sitediagrams.py" - "scripts/siteentries.py" @@ -89,6 +91,9 @@ on: - "install.py" - "install.sh" - "install.ps1" + # The handheld launchers it serves are built from ports/ at the same + # commit (scripts/sitehandheld.py). + - "ports/**" - "database.json" - "release.json" - "derivable.json" diff --git a/scripts/build_port.py b/scripts/build_port.py index dbedf0537..b09b5b07d 100644 --- a/scripts/build_port.py +++ b/scripts/build_port.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Build the handheld launchers in ports/ into their archives in dist/. +"""Build the handheld launchers in ports/ into their archives. RetroBIOS.zip the PortMaster port (ports/RetroBIOS) RetroBIOS-batocera.zip Batocera and Knulli, from Ports (ports/batocera) @@ -17,13 +17,16 @@ the applications folder, so its top folder is the application. install.py is copied from the repository root, and only when it is the installer the bootstraps pin, so every launcher runs the same bytes as the -one-line command of the same commit. Members are written as the packs write +one-line command of the same commit. The site builds them at each deploy +and serves them under get/handheld/ (scripts/sitehandheld.py): they are no +release files, and are written to tmp/handheld/ by default, away from the +packs of dist/. 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 archives. Usage: python scripts/build_port.py - python scripts/build_port.py --output-dir tmp/port + python scripts/build_port.py --output-dir tmp/port # default: tmp/handheld """ from __future__ import annotations @@ -44,7 +47,7 @@ PORT_ZIP = "RetroBIOS.zip" BATOCERA_ZIP = "RetroBIOS-batocera.zip" MUOS_APP = "RetroBIOS.muxapp" TOOLS_ZIP = "RetroBIOS-tools.zip" -# Every archive a release attaches, in the order the release notes list them. +# Every launcher archive the site serves. ARCHIVES = (PORT_ZIP, BATOCERA_ZIP, MUOS_APP, TOOLS_ZIP) # Which release pack serves each platform, read by tui.txt offline. PACKS_TABLE = "packs.txt" @@ -201,8 +204,8 @@ def main() -> int: parser = argparse.ArgumentParser( allow_abbrev=False, description="Build the RetroBIOS handheld launchers" ) - parser.add_argument("--output-dir", type=Path, default=REPO_ROOT / "dist", - help="where the archives are written (default: dist/)") + parser.add_argument("--output-dir", type=Path, default=REPO_ROOT / "tmp" / "handheld", + help="where the archives are written (default: tmp/handheld/)") args = parser.parse_args() try: with artifact_lock(str(args.output_dir)): diff --git a/scripts/generate_installer_doc.py b/scripts/generate_installer_doc.py index 5a1b795e8..39d88a3a4 100644 --- a/scripts/generate_installer_doc.py +++ b/scripts/generate_installer_doc.py @@ -8,7 +8,8 @@ Each table sits between `` and ``. What install.py knows is written from it: the options and their arguments from its parser, the event kinds and their keys from EVENT_KEYS, the exit codes from its sys.exit calls, the RETROBIOS_PAD -values and each route's keys from TUI_ROUTES. The prose cells (what an option +values and each route's keys from TUI_ROUTES, the handheld launchers from +sitehandheld.LAUNCHERS. The prose cells (what an option does, when an event comes) are kept as written, matched by their row. A row install.py gains has no prose yet: the generator names it and writes nothing, and the row is added by hand inside the markers, its first cell holding the @@ -45,6 +46,7 @@ import zipfile from pathlib import Path, PurePosixPath from typing import Callable +import sitehandheld from artifacts import write_text_atomic ROOT = Path(__file__).resolve().parent.parent @@ -622,6 +624,10 @@ def render(page: str) -> str: "card-markers": lambda _body: _table(["On the card", "System", "Files go to"], card_marker_rows()), "line-menu": lambda _body: line_menu(), + # Where each handheld launcher goes, written from the site's own + # description of it (sitehandheld.LAUNCHERS). + "handheld": lambda _body: "\n".join(sitehandheld.table_rows()), + "handheld-downloads": lambda _body: "\n".join(sitehandheld.download_rows()), } seen = [] with contextlib.ExitStack() as stack: diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index fcda2e74d..dcf32b9bc 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -5819,7 +5819,7 @@ def _pack_archives(output_dir: str) -> list[str]: fail conformance, and injecting a manifest would change published bytes. Every name the builder gives holds `_BIOS_Pack`, then the system tag of a --system platform pack (`_BIOS_Pack_Playstation.zip`): build_port.py - writes its launcher archives (RetroBIOS.zip) to the same dist/, and + wrote its launcher archives (RetroBIOS.zip) to the same dist/, and taking every ZIP rewrote them with a manifest after build_port had printed their hashes. """ diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 34369fd51..8272392a7 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -127,6 +127,7 @@ from siteguides import ( import archive_build import release_record import sitebootstrap +import sitehandheld import sitemeasurements # The Starlight project, and where its pages go inside it. @@ -859,6 +860,7 @@ def generate_get_page(platform_name: str) -> str: f"[Why no ZIP?](../{NO_ZIP_FAQ})", "", ] + lines += sitehandheld.platform_block(platform_name, _REPO_ROOT) lines += _next_block([ f"[{display}](../platforms/{platform_name}.md#install): the folder, " "and how to run the command on the device", @@ -886,6 +888,11 @@ def generate_get_index() -> str: for name in names: display = load_platform_config(name, str(siteinputs.current().platforms)).get("platform", name) lines.append(f"- [{get_page_title(name, display)}]({name}.md)") + # The handheld app sits with the group whose tile leads to the + # handheld guide. + if any(HANDHELD_GUIDE and tile.link == HANDHELD_GUIDE.path + for tile in GROUP_TILES.get(group, ())): + lines.append(f"- [{sitehandheld.GROUP_TITLE}]({sitehandheld.GROUP_PAGE}.md)") lines.append("") return "\n".join(lines) @@ -4512,6 +4519,10 @@ def generate_handheld_guide(guide: HandheldGuide) -> str: "", *_guide_download(guide.uses, display, uses, None, "## Download { #download }"), *_offline_line(guide.uses), + f"On {sitehandheld.joined(sitehandheld.group_systems(), 'or')}: " + f"[{sitehandheld.GROUP_TITLE}]" + f"(../get/{sitehandheld.GROUP_PAGE}.md).", + "", "## Fill the card from a computer { #install }", "", "On Windows, note the card's letter, like **E:**. Put its BIOS folder after " @@ -12472,6 +12483,8 @@ def _generate(args: argparse.Namespace) -> None: (public / "robots.txt").unlink(missing_ok=True) # The one-line commands' bootstraps, at the site's root. sitebootstrap.write_bootstraps(public, _REPO_ROOT) + # The handheld launchers, built from the same commit (sitehandheld). + sitehandheld.write_launchers(public, _REPO_ROOT) images_dest = public / "assets" / "images" images_dest.mkdir(parents=True, exist_ok=True) @@ -12820,6 +12833,8 @@ def _generate(args: argparse.Namespace) -> None: get_dir.mkdir(parents=True, exist_ok=True) for name in platform_names: write_if_changed(str(get_dir / f"{name}.md"), generate_get_page(name)) + write_if_changed(str(get_dir / f"{sitehandheld.GROUP_PAGE}.md"), + sitehandheld.group_page(_REPO_ROOT)) write_if_changed(str(get_dir / "index.md"), generate_get_index()) print("Generating contributing page...") diff --git a/scripts/sitehandheld.py b/scripts/sitehandheld.py new file mode 100644 index 000000000..36749d4f4 --- /dev/null +++ b/scripts/sitehandheld.py @@ -0,0 +1,258 @@ +"""The handheld launchers the site serves, and where each one goes. + +The four archives scripts/build_port.py writes carry install.py pinned by +SHA-256, so they change with the installer, not with the BIOS packs. The +site builds them from the commit it is built from and serves them under +get/handheld/, beside the bootstraps that pin the same installer +(sitebootstrap.py). build_archive refuses an install.py that install.sh +does not pin: a stale pin stops the site build, the last good archives +staying served. + +LAUNCHERS is the one description of where each archive goes on the card +and which menu starts it. The handheld tables of wiki/installer.md are +written from it (scripts/generate_installer_doc.py), and so is the block +each get// page and the handheld page carry. +""" + +from __future__ import annotations + +import hashlib +import tempfile +from dataclasses import dataclass, field +from pathlib import Path + +import sitemarkup as markup +from artifacts import write_bytes_atomic +from build_port import ARCHIVES, BATOCERA_ZIP, MUOS_APP, PORT_ZIP, TOOLS_ZIP, build_archive + +REPO_ROOT = Path(__file__).resolve().parent.parent +# Below the site's root: get/handheld/RetroBIOS.zip. +SERVED_DIR = "get/handheld" +# The page of the systems no registered platform answers for. It shares +# its name with the handheld guide (platforms/linux-handhelds.md), so the +# sidebar files it with that guide, as get/batocera.md is with +# platforms/batocera.md. +GROUP_PAGE = "linux-handhelds" +GROUP_TITLE = "RetroBIOS app for handhelds" + + +@dataclass(frozen=True) +class Launcher: + """One row of the installer's handheld table. + + *systems* are named as the installer page names them. *copy_to* is + where the file goes, and *places* gives a system its own folder when + the row's systems differ there. *lead* opens the row's first cell. + """ + + archive: str + systems: tuple[str, ...] + copy_to: str + start: str + extracted: bool = False + lead: str = "" + places: tuple[tuple[str, tuple[str, ...]], ...] = field(default=()) + + def folder_for(self, system: str) -> str: + return next((folder for folder, systems in self.places if system in systems), + self.copy_to) + + +LAUNCHERS: tuple[Launcher, ...] = ( + Launcher(MUOS_APP, ("muOS",), + "`ARCHIVE` at the root of the card, then install it from the Archive Manager", + "Applications"), + Launcher(PORT_ZIP, ("ArkOS", "dArkOS", "ROCKNIX", "AmberELEC", "JELOS", "KNULLI"), + "PortMaster's `autoinstall` folder", "Ports, after opening PortMaster once", + lead="Any other system with [PortMaster](https://portmaster.games/)", + places=(("`/roms/ports/PortMaster/autoinstall/`", ("ROCKNIX", "AmberELEC")), + ("`/roms/tools/PortMaster/autoinstall/`", ("ArkOS",)), + ("`/userdata/system/.local/share/PortMaster/autoinstall/`", ("KNULLI",)))), + Launcher(BATOCERA_ZIP, ("Batocera", "KNULLI"), "`roms/ports` on the SHARE partition", + "Ports, after updating the game list", extracted=True), + Launcher(TOOLS_ZIP, ("ArkOS", "dArkOS"), "`tools` on the EASYROMS partition", + "Options, then Tools", extracted=True), + Launcher(TOOLS_ZIP, ("ROCKNIX", "JELOS"), + "`/storage/.config/modules`, over the network share or SSH", "Tools", + extracted=True), + Launcher(TOOLS_ZIP, ("AmberELEC",), "`/storage/.config/distribution/modules`", "Tools", + extracted=True), +) + +# The registered platform whose get/ page answers for a system. A system +# outside this map is on the handheld page. +PLATFORM_OF = {"Batocera": "batocera", "KNULLI": "knulli", "ROCKNIX": "rocknix", + "AmberELEC": "amberelec"} + + +def systems() -> list[str]: + """Every system a launcher runs on, in table order.""" + return list(dict.fromkeys(name for launcher in LAUNCHERS for name in launcher.systems)) + + +def group_systems() -> list[str]: + return [name for name in systems() if name not in PLATFORM_OF] + + +def joined(names: list[str], last: str = "and") -> str: + """muOS, ArkOS and JELOS.""" + return names[0] if len(names) == 1 else f"{', '.join(names[:-1])} {last} {names[-1]}" + + +def page_of(system: str) -> str: + """The site page that holds the download for *system*.""" + return f"get/{PLATFORM_OF.get(system, GROUP_PAGE)}.md" + + +def launchers_for(system: str) -> list[Launcher]: + """The launchers of *system*, its own menu first and PortMaster last.""" + own = [launcher for launcher in LAUNCHERS if system in launcher.systems] + return sorted(own, key=lambda launcher: launcher.archive == PORT_ZIP) + + +@dataclass(frozen=True) +class Served: + data: bytes + + @property + def size(self) -> int: + return len(self.data) + + @property + def sha256(self) -> str: + return hashlib.sha256(self.data).hexdigest() + + +_built: dict[tuple[str, str], Served] = {} + + +def served(name: str, root: Path = REPO_ROOT) -> Served: + """The bytes of archive *name* as build_port builds it from *root*, + built once per run: the page states the size and hash of the file the + same run writes.""" + key = (str(root.resolve()), name) + if key not in _built: + scratch_root = root / "tmp" + scratch_root.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=scratch_root, prefix="handheld-") as scratch: + path = build_archive(name, Path(scratch) / name, root) + _built[key] = Served(path.read_bytes()) + return _built[key] + + +def write_launchers(public: Path, root: Path = REPO_ROOT) -> list[Path]: + """Write every launcher archive under the site's public folder, each + only when its bytes moved.""" + folder = public / SERVED_DIR + folder.mkdir(parents=True, exist_ok=True) + written = [] + for name in ARCHIVES: + path = folder / name + data = served(name, root).data + if not path.is_file() or path.read_bytes() != data: + write_bytes_atomic(str(path), data) + written.append(path) + return written + + +def table_rows() -> list[str]: + """The handheld table of the installer page, each system linked to the + page that holds its download. The page sits in wiki/.""" + def name(system: str) -> str: + return f"[{system}](../{page_of(system)})" + + rows = ["| System | File | Copy it to | Start it from |", + "|--------|------|------------|---------------|"] + for launcher in LAUNCHERS: + names = ", ".join(name(system) for system in launcher.systems) + first = f"{launcher.lead}: {names}" if launcher.lead else names + file = f"`{launcher.archive}`" + (", extracted" if launcher.extracted else "") + copy_to = launcher.copy_to + if launcher.places: + copy_to += ": " + ", ".join( + f"{folder} on {' and '.join(where)}" for folder, where in launcher.places) + rows.append(f"| {first} | {file} | {copy_to} | {launcher.start} |") + return rows + + +def _anchor(system: str) -> str: + """Where *system*'s downloads sit on its page.""" + return "on-the-handheld" if system in PLATFORM_OF else system.casefold() + + +def download_rows() -> list[str]: + """The short table that opens the installer's handheld section: each + system, its files as links to the site's copies, and its page. The + page sits in wiki/.""" + rows = ["| System | Download | Steps |", "|--------|----------|-------|"] + for system in systems(): + files = ", ".join( + f"[{launcher.archive}](../{SERVED_DIR}/{launcher.archive})" + + (" (PortMaster)" if launcher.archive == PORT_ZIP else "") + for launcher in launchers_for(system)) + page = f"../{page_of(system)}#{_anchor(system)}" + rows.append(f"| {system} | {files} | [{system} steps]({page}) |") + return rows + + +def _launcher_block(launcher: Launcher, system: str, root: Path) -> list[str]: + archive = served(launcher.archive, root) + verb = "Unzip it into" if launcher.extracted else "Copy it to" + via = " (with PortMaster)" if launcher.archive == PORT_ZIP else "" + return [ + markup.button(f"Download {launcher.archive}", f"handheld/{launcher.archive}", + primary=True) + via, + "", + f"- {verb} {launcher.folder_for(system)}.", + f"- Start it from {launcher.start}.", + f"- {_size(archive.size)}. SHA-256: `{archive.sha256}`", + "", + ] + + +def _size(count: int) -> str: + if count >= 1024 * 1024: + return f"{count / 1024 ** 2:.1f} MB" + return f"{count / 1024:.0f} KB" + + +APP_LINE = "RetroBIOS also runs on the handheld, with the controller." +# The installer's handheld section opens on the download of every system. +INSTALLER_SECTION = "wiki/installer.md#on-a-handheld-the-retrobios-port" +DETAILS_LINK = f"[Every system's file](../{INSTALLER_SECTION})" + + +def platform_block(platform: str, root: Path = REPO_ROOT) -> list[str]: + """The app section of get//, empty for a platform no launcher + runs on.""" + system = next((name for name, key in PLATFORM_OF.items() if key == platform), None) + if system is None: + return [] + lines = ["## On the handheld { #on-the-handheld }", "", APP_LINE, ""] + for launcher in launchers_for(system): + lines += _launcher_block(launcher, system, root) + return [*lines, DETAILS_LINK, ""] + + +def group_page(root: Path = REPO_ROOT) -> str: + """get/linux-handhelds/: the app for the systems no setup page answers + for, then the setup pages that carry it for the others.""" + names = group_systems() + listed = joined(names) + lines = [ + "---", "search: exclude", + f'description: "The RetroBIOS app for {listed}: the file to put on the card ' + 'and the menu that starts it."', + "---", "", + f"# {GROUP_TITLE}", "", + f"{APP_LINE} Each system has its file below.", "", + ] + for system in names: + lines += [f"## {system}", ""] + for launcher in launchers_for(system): + lines += _launcher_block(launcher, system, root) + others = [f"[{name}]({PLATFORM_OF[name]}.md#on-the-handheld)" + for name in sorted(PLATFORM_OF, key=str.casefold)] + lines += ["## Other systems", "", f"On {joined(others, 'or')}: on its page.", "", + DETAILS_LINK, ""] + return "\n".join(lines) diff --git a/tests/test_site_handheld.py b/tests/test_site_handheld.py new file mode 100644 index 000000000..ae81d0cd6 --- /dev/null +++ b/tests/test_site_handheld.py @@ -0,0 +1,168 @@ +"""The handheld launchers the site serves. + +The four archives build_port.py writes carry install.py pinned by SHA-256, +so they left the release (owner, 2026-11-10): the site builds them from its +own commit, serves them under get/handheld/, and the get/ page of each +system names the file, its size and hash, where it goes and which menu +starts it. +""" + +from __future__ import annotations + +import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache + +import contextlib +import hashlib +import re +import shutil +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +import build_port # noqa: E402 +import common # noqa: E402 +import sitehandheld # noqa: E402 + +PLATFORMS = str(REPO_ROOT / "platforms") + + +def _pages() -> dict[str, str]: + """Every page that carries a launcher, by site path.""" + import generate_site # noqa: PLC0415 + + with contextlib.chdir(REPO_ROOT): + pages = {f"get/{key}.md": generate_site.generate_get_page(key) + for key in sorted(set(sitehandheld.PLATFORM_OF.values()))} + pages[f"get/{sitehandheld.GROUP_PAGE}.md"] = sitehandheld.group_page(REPO_ROOT) + return pages + + +class TheSiteServesTheLaunchers(unittest.TestCase): + def setUp(self): + scratch = REPO_ROOT / "tmp" + scratch.mkdir(exist_ok=True) + self.tmp = Path(tempfile.mkdtemp(dir=scratch, prefix="site-handheld-")) + self.addCleanup(shutil.rmtree, self.tmp, True) + + def test_the_written_archives_are_the_ones_build_port_writes(self): + written = sitehandheld.write_launchers(self.tmp / "public", REPO_ROOT) + self.assertEqual([path.name for path in written], list(build_port.ARCHIVES)) + for path in written: + with self.subTest(archive=path.name): + self.assertEqual(path.parent, self.tmp / "public" / "get" / "handheld") + built = build_port.build_archive(path.name, self.tmp / "built" / path.name) + self.assertEqual(path.read_bytes(), built.read_bytes()) + + def test_an_unchanged_archive_is_not_rewritten(self): + first = sitehandheld.write_launchers(self.tmp / "public", REPO_ROOT) + stamps = {path: path.stat().st_mtime_ns for path in first} + sitehandheld.write_launchers(self.tmp / "public", REPO_ROOT) + self.assertEqual({path: path.stat().st_mtime_ns for path in first}, stamps) + + def test_a_stale_pin_stops_the_build(self): + """build_port refuses an install.py the bootstraps do not pin: served, + that launcher would refuse its own installer.""" + with mock.patch.dict(sitehandheld._built, clear=True), \ + mock.patch.object(build_port, "pinned_installer_sha256", return_value="0" * 64): + with self.assertRaisesRegex(ValueError, "re-pin the bootstraps"): + sitehandheld.write_launchers(self.tmp / "public", REPO_ROOT) + self.assertFalse(list((self.tmp / "public").rglob("RetroBIOS*"))) + + def test_every_archive_is_on_a_page_with_its_link_and_hash(self): + public = self.tmp / "public" + written = {path.name: path for path in sitehandheld.write_launchers(public, REPO_ROOT)} + pages = _pages() + for name in build_port.ARCHIVES: + with self.subTest(archive=name): + data = written[name].read_bytes() + digest = hashlib.sha256(data).hexdigest() + holding = [page for page, text in pages.items() + if f'href="handheld/{name}"' in text] + self.assertTrue(holding, f"no page links {name}") + for page in holding: + text = pages[page] + # The link resolves to the file written under public/. + target = (Path(page).parent / "handheld" / name).as_posix() + self.assertTrue((public / target).is_file(), target) + block = text.split(f'href="handheld/{name}"', 1)[1].split("\n\n", 2)[1] + self.assertIn(f"SHA-256: `{digest}`", block) + self.assertIn(sitehandheld._size(len(data)), block) + self.assertRegex(block, r"- (Copy it to|Unzip it into) .+\.") + self.assertRegex(block, r"- Start it from .+\.") + + +class WhereEachSystemFindsItsFile(unittest.TestCase): + def test_each_platform_page_is_a_registered_platform(self): + registered = set(common.list_registered_platforms(PLATFORMS)) + self.assertLessEqual(set(sitehandheld.PLATFORM_OF.values()), registered) + self.assertNotIn(sitehandheld.GROUP_PAGE, registered) + + def test_the_group_page_sits_with_the_handheld_guide(self): + """It shares the guide's name, so the sidebar files it with it, and + the guide a muOS or ArkOS owner lands on links it.""" + import generate_site # noqa: PLC0415 + + guide = generate_site.HANDHELD_GUIDE + self.assertEqual(sitehandheld.GROUP_PAGE, guide.page) + with contextlib.chdir(REPO_ROOT): + self.assertIn(f"(../get/{sitehandheld.GROUP_PAGE}.md)", + generate_site.generate_handheld_guide(guide)) + self.assertIn(f"]({sitehandheld.GROUP_PAGE}.md)", generate_site.generate_get_index()) + + def test_every_system_has_its_launchers_on_its_page(self): + pages = _pages() + for system in sitehandheld.systems(): + page = sitehandheld.page_of(system) + with self.subTest(system=system): + self.assertIn(page, pages) + for launcher in sitehandheld.launchers_for(system): + self.assertIn(f'href="handheld/{launcher.archive}"', pages[page]) + self.assertIn(launcher.folder_for(system), pages[page]) + self.assertIn(launcher.start, pages[page]) + for system in sitehandheld.group_systems(): + self.assertIn(f"\n## {system}\n", pages[f"get/{sitehandheld.GROUP_PAGE}.md"]) + + def test_the_installer_table_is_written_from_the_same_rows(self): + text = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8") + block = re.search(r"\n(.*?)", + text, re.S) + self.assertIsNotNone(block) + self.assertEqual(block.group(1).strip(), "\n".join(sitehandheld.table_rows())) + self.assertNotIn("releases/latest", text.split("## On a handheld", 1)[1].split("\n## ")[0]) + + def test_the_installer_section_opens_on_the_downloads(self): + """The section was long, with no link to click (owner, 2026-11-10): + it opens on one row per system, each file a link to its copy on the + site, before the detail.""" + text = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8") + section = text.split("## On a handheld", 1)[1].split("\n## ")[0] + block = re.search(r"\n(.*?)" + r"", section, re.S) + self.assertIsNotNone(block) + self.assertEqual(block.group(1).strip(), "\n".join(sitehandheld.download_rows())) + self.assertLess(block.start(), section.index("### Where each file goes")) + self.assertLess(len(section[:block.start()].split()), 40) + for system in sitehandheld.systems(): + with self.subTest(system=system): + row = next(line for line in block.group(1).splitlines() + if line.startswith(f"| {system} |")) + for launcher in sitehandheld.launchers_for(system): + self.assertIn(f"](../{sitehandheld.SERVED_DIR}/{launcher.archive})", row) + self.assertIn(f"](../{sitehandheld.page_of(system)}#", row) + for name in build_port.ARCHIVES: + self.assertIn(f"](../{sitehandheld.SERVED_DIR}/{name})", block.group(1)) + + def test_the_generator_writes_them(self): + source = (REPO_ROOT / "scripts" / "generate_site.py").read_text(encoding="utf-8") + self.assertIn("sitehandheld.write_launchers(public, _REPO_ROOT)", source) + self.assertIn("sitehandheld.platform_block(platform_name, _REPO_ROOT)", source) + self.assertIn("sitehandheld.group_page(_REPO_ROOT)", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/wiki/installer.md b/wiki/installer.md index 7dcd94386..920f1860e 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1501,27 +1501,45 @@ Android as on other systems, carries the same empty `.nomedia` beside ## On a handheld { #on-a-handheld-the-retrobios-port } -RetroBIOS also runs on the handheld itself, in the -[full-screen interface](#the-full-screen-interface), with the controller. -Copy the file for the system to the SD card, then start RetroBIOS from the -menu. +RetroBIOS also runs on the handheld, with the controller. Download the file +for the system, copy it to the SD card, then start RetroBIOS from the menu. -Download the file for your system from the -[latest release](https://github.com/Abdess/retrobios/releases/latest). + + +| System | Download | Steps | +|--------|----------|-------| +| muOS | [RetroBIOS.muxapp](../get/handheld/RetroBIOS.muxapp) | [muOS steps](../get/linux-handhelds.md#muos) | +| ArkOS | [RetroBIOS-tools.zip](../get/handheld/RetroBIOS-tools.zip), [RetroBIOS.zip](../get/handheld/RetroBIOS.zip) (PortMaster) | [ArkOS steps](../get/linux-handhelds.md#arkos) | +| dArkOS | [RetroBIOS-tools.zip](../get/handheld/RetroBIOS-tools.zip), [RetroBIOS.zip](../get/handheld/RetroBIOS.zip) (PortMaster) | [dArkOS steps](../get/linux-handhelds.md#darkos) | +| ROCKNIX | [RetroBIOS-tools.zip](../get/handheld/RetroBIOS-tools.zip), [RetroBIOS.zip](../get/handheld/RetroBIOS.zip) (PortMaster) | [ROCKNIX steps](../get/rocknix.md#on-the-handheld) | +| AmberELEC | [RetroBIOS-tools.zip](../get/handheld/RetroBIOS-tools.zip), [RetroBIOS.zip](../get/handheld/RetroBIOS.zip) (PortMaster) | [AmberELEC steps](../get/amberelec.md#on-the-handheld) | +| JELOS | [RetroBIOS-tools.zip](../get/handheld/RetroBIOS-tools.zip), [RetroBIOS.zip](../get/handheld/RetroBIOS.zip) (PortMaster) | [JELOS steps](../get/linux-handhelds.md#jelos) | +| KNULLI | [RetroBIOS-batocera.zip](../get/handheld/RetroBIOS-batocera.zip), [RetroBIOS.zip](../get/handheld/RetroBIOS.zip) (PortMaster) | [KNULLI steps](../get/knulli.md#on-the-handheld) | +| Batocera | [RetroBIOS-batocera.zip](../get/handheld/RetroBIOS-batocera.zip) | [Batocera steps](../get/batocera.md#on-the-handheld) | + + +### Where each file goes { #where-each-file-goes } + +It runs in the [full-screen interface](#the-full-screen-interface). + + | System | File | Copy it to | Start it from | |--------|------|------------|---------------| -| muOS | `RetroBIOS.muxapp` | `ARCHIVE` at the root of the card, then install it from the Archive Manager | Applications | -| Any other system with [PortMaster](https://portmaster.games/): ArkOS, dArkOS, ROCKNIX, AmberELEC, JELOS, Knulli | `RetroBIOS.zip` | PortMaster's `autoinstall` folder: `/roms/ports/PortMaster/autoinstall/` on ROCKNIX and AmberELEC, `/roms/tools/PortMaster/autoinstall/` on ArkOS, `/userdata/system/.local/share/PortMaster/autoinstall/` on Knulli | Ports, after opening PortMaster once | -| Batocera, Knulli | `RetroBIOS-batocera.zip`, extracted | `roms/ports` on the SHARE partition | Ports, after updating the game list | -| ArkOS, dArkOS | `RetroBIOS-tools.zip`, extracted | `tools` on the EASYROMS partition | Options, then Tools | -| ROCKNIX, JELOS | `RetroBIOS-tools.zip`, extracted | `/storage/.config/modules`, over the network share or SSH | Tools | -| AmberELEC | `RetroBIOS-tools.zip`, extracted | `/storage/.config/distribution/modules` | Tools | +| [muOS](../get/linux-handhelds.md) | `RetroBIOS.muxapp` | `ARCHIVE` at the root of the card, then install it from the Archive Manager | Applications | +| Any other system with [PortMaster](https://portmaster.games/): [ArkOS](../get/linux-handhelds.md), [dArkOS](../get/linux-handhelds.md), [ROCKNIX](../get/rocknix.md), [AmberELEC](../get/amberelec.md), [JELOS](../get/linux-handhelds.md), [KNULLI](../get/knulli.md) | `RetroBIOS.zip` | PortMaster's `autoinstall` folder: `/roms/ports/PortMaster/autoinstall/` on ROCKNIX and AmberELEC, `/roms/tools/PortMaster/autoinstall/` on ArkOS, `/userdata/system/.local/share/PortMaster/autoinstall/` on KNULLI | Ports, after opening PortMaster once | +| [Batocera](../get/batocera.md), [KNULLI](../get/knulli.md) | `RetroBIOS-batocera.zip`, extracted | `roms/ports` on the SHARE partition | Ports, after updating the game list | +| [ArkOS](../get/linux-handhelds.md), [dArkOS](../get/linux-handhelds.md) | `RetroBIOS-tools.zip`, extracted | `tools` on the EASYROMS partition | Options, then Tools | +| [ROCKNIX](../get/rocknix.md), [JELOS](../get/linux-handhelds.md) | `RetroBIOS-tools.zip`, extracted | `/storage/.config/modules`, over the network share or SSH | Tools | +| [AmberELEC](../get/amberelec.md) | `RetroBIOS-tools.zip`, extracted | `/storage/.config/distribution/modules` | Tools | + `RetroBIOS.zip` also installs on muOS, from `MUOS/PortMaster/autoinstall/` on the first card (`/mnt/mmc` on the device). It then shows the interface in muxterm, as the muOS app does. +### Buttons on a handheld { #buttons-on-a-handheld } + | Button | Action | |--------|--------| | D-pad | Move | diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index 2c1f6a867..a94265a99 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -60,7 +60,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_amberelec_targets`, `test_emuelec_targets`, `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_filesystems`, `test_install_app`, `test_install_from`, `test_install_games`, `test_install_journeys`, `test_install_offline`, `test_install_selection`, `test_install_tui`, `test_pypi_package` | -| Handheld launchers | a pty as the console, fake installer and firmware tools, stubbed PortMaster dialogs | `test_handheld_launchers`, `test_port` | +| Handheld launchers | a pty as the console, fake installer and firmware tools, stubbed PortMaster dialogs | `test_handheld_launchers`, `test_port`, `test_site_handheld` | | Running cores | parsers on fixed inputs, a libretro core built from C in the test when a compiler exists | `test_core_probe` | | Measurement records | synthetic record files, the committed `measurements/` against HEAD, the **Proof** line naming the BIOS a core ran, and that no page shows a Tested block, a region outcome or a time per frame | `test_measurements` | | Real-condition campaign | synthetic profiles, records and content folders, a fake host for load, disk, clock and commands, and whole passes against a libretro core built from C when a compiler and strace exist | `test_campaign` |