mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-11 12:33:53 -06:00
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.
This commit is contained in:
1 parent
d457948143
commit
d7bd89d42b
9 files changed
+494
-21
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -8,7 +8,8 @@ Each table sits between `<!-- generated: NAME -->` and
|
||||
`<!-- /generated: NAME -->`. 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:
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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...")
|
||||
|
||||
@@ -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/<platform>/ 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/<platform>/, 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)
|
||||
@@ -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"<!-- generated: handheld -->\n(.*?)<!-- /generated: handheld -->",
|
||||
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"<!-- generated: handheld-downloads -->\n(.*?)"
|
||||
r"<!-- /generated: handheld-downloads -->", 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()
|
||||
+30
-12
@@ -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).
|
||||
<!-- generated: handheld-downloads -->
|
||||
|
||||
| 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) |
|
||||
<!-- /generated: handheld-downloads -->
|
||||
|
||||
### Where each file goes { #where-each-file-goes }
|
||||
|
||||
It runs in the [full-screen interface](#the-full-screen-interface).
|
||||
|
||||
<!-- generated: handheld -->
|
||||
|
||||
| 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 |
|
||||
<!-- /generated: handheld -->
|
||||
|
||||
`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 |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
Reference in new issue
Block a user