chore: merge the launchers served by the site

This commit is contained in:
Abdessamad Derraz committed 2026-11-10 12:03:29 +01:00
commit fba9a0b511
17 files changed
+837 -80

No files matched your search

+9 -6
View File
@@ -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)):
+6 -9
View File
@@ -3,13 +3,13 @@
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, as must each handheld launcher (RetroBIOS.zip
and the others build_port.py writes) the list names, the parts of a pack must add up to the download
SHA256SUMS.txt lists for it, the parts of a pack must add up to the download
size the README prints, and nothing else may be attached. The record holds
one pack for each platform the registry marks release_pack, and no other
pack. The installer and the file lists are not release files: a package pins
them from the tag, so a reader of the release page sees the packs and nothing
to puzzle over. An upload that
pack. The installer, the file lists and the handheld launchers are not
release files: a package pins the first two from the tag, and the site
serves the launchers built from its own commit (sitehandheld.py), so a
reader of the release page sees the packs and nothing to puzzle over. 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.
@@ -31,7 +31,6 @@ from dataclasses import dataclass
from pathlib import Path
sys.path.insert(0, os.path.dirname(__file__))
from build_port import ARCHIVES
from check_release_assets import digests_of
from common import list_release_pack_platforms, require_directory
from largefiles import LARGE_FILES_REPO
@@ -112,9 +111,7 @@ 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}"))
# The handheld launchers are release files but no packs: release.json
# records packs, and the checksum list alone vouches for the launchers.
for name in sorted(set(sums) - set(recorded) - set(ARCHIVES)):
for name in sorted(set(sums) - set(recorded)):
findings.append(Finding("unrecorded", name, f"listed in {SUMS}, absent from release.json"))
wanted = {**sums, **(local_files or {})}
+7 -1
View File
@@ -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:
+1 -1
View File
@@ -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.
"""
+15
View File
@@ -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...")
+141 -12
View File
@@ -11,6 +11,14 @@ idea per line, plain words (owner, 2026-11-10). The maintainer writes
what changed, and that text is appended as given once it holds none of the
REFUSED phrases.
The notes end with the people to thank and the full changelog, from the
previous release's record commit (the first commit release.json gave the
previous tag) to the new tag. The thanks name everyone who helped since the
repository started, read with gh: a login is only ever the one GitHub links
to the work, never one guessed from a git author name. The owner, bots and
spam are left out (LEFT_OUT). --thanks FILE passes a checked list instead
of the one gh gives.
Usage:
python scripts/release_notes.py --whats-new tmp/whats-new.md --output notes.md
"""
@@ -18,8 +26,11 @@ Usage:
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from collections.abc import Callable
from pathlib import Path
import release_record
@@ -34,7 +45,12 @@ from common import (
from siteguides import install_command
REPO = "Abdess/retrobios"
OWNER = "Abdess"
SITE = "https://abdess.github.io/retrobios/"
# The handheld app is served by the site, built from its own commit, never
# attached to a release. The installer's handheld section opens on the
# download of every system.
HANDHELD_PAGE = "wiki/installer/#on-a-handheld-the-retrobios-port"
def _size(count: int) -> str:
@@ -79,14 +95,16 @@ def without_pack(platforms_dir: str) -> list[str]:
def no_pack_paragraph(platforms_dir: str) -> list[str]:
"""One paragraph naming the setups served by the command alone."""
"""One paragraph naming the setups served by the command alone. The
packs published are the most downloaded ones (wiki/release-process.md,
"Which platforms get a pack")."""
names = without_pack(platforms_dir)
if not names:
return []
listed = ", ".join(names[:-1]) + f" and {names[-1]}" if len(names) > 1 else names[0]
return [
(f"No ZIP for {listed}. The command above installs them."
" Add `--dry-run` to see what it would do."),
("Only the most used setups have a pack, to keep the release small."
f" For {listed}, use the command above."),
"",
]
@@ -119,31 +137,126 @@ def refused_words(text: str) -> list[str]:
return [phrase for phrase in REFUSED if phrase in lowered]
def render(record: dict, platforms_dir: str, whats_new: str) -> str:
Run = Callable[[list[str]], str]
def _run(argv: list[str]) -> str:
return subprocess.run(argv, capture_output=True, text=True, check=True).stdout
def previous_record_commit(tag: str, run: Run | None = None) -> str:
"""The commit that recorded the release before *tag*: the oldest commit
of the run of release.json changes that name the previous tag."""
run = run or _run
shas = run(["git", "log", "--format=%H", "--", "release.json"]).split()
previous, base = None, None
for sha in shas:
recorded = json.loads(run(["git", "show", f"{sha}:release.json"])).get("tag")
if previous is None:
if recorded != tag:
previous, base = recorded, sha
elif recorded == previous:
base = sha
else:
break
if base is None:
raise ValueError(f"release.json records no release before {tag}")
return base
# Accounts that never helped: the owner's own, GitHub's placeholder for a
# deleted account, and spam (issue 32 by Bappo0 and pull request 67 by
# prakashmagat50-byte, both empty or garbage).
LEFT_OUT = frozenset(("Abdess", "Abdess76", "ghost", "Bappo0", "prakashmagat50-byte"))
def _person(login: str | None) -> str | None:
"""The login to thank, None for the owner, a bot, a deleted account,
spam, or work GitHub links to no account."""
if not login or login in LEFT_OUT or login.endswith("[bot]") or login.startswith("app/"):
return None
return login
def _logins(text: str) -> set[str]:
"""One login per line, as `gh api --paginate --jq` prints them."""
return {person for line in text.splitlines() if (person := _person(line.strip()))}
def _authors(text: str) -> set[str]:
"""The authors of a `gh pr list` or `gh issue list --json author`."""
return {person for item in json.loads(text)
if not (item.get("author") or {}).get("is_bot")
and (person := _person((item.get("author") or {}).get("login")))}
def gather_thanks(run: Run | None = None, repo: str = REPO) -> list[str]:
"""Everyone who helped since the repository started, each named once:
commit authors as the commits API links them to an account and every
pull request author, merged or not, then issue authors, then whoever
commented on an issue or a pull request."""
run = run or _run
code = _logins(run(["gh", "api", "--paginate", f"repos/{repo}/commits",
"--jq", ".[] | .author.login // empty"]))
code |= _authors(run(["gh", "pr", "list", "-R", repo, "--state", "all",
"--limit", "10000", "--json", "number,author"]))
reports = _authors(run(["gh", "issue", "list", "-R", repo, "--state", "all",
"--limit", "10000", "--json", "number,author"])) - code
helped = set()
for path in (f"repos/{repo}/issues/comments", f"repos/{repo}/pulls/comments"):
helped |= _logins(run(["gh", "api", "--paginate", path, "--jq", ".[] | .user.login"]))
helped -= code | reports
lines = []
for label, people in (("Code and data", code), ("Reports", reports),
("Help in issues", helped)):
if people:
names = [f"@{login}" for login in sorted(people, key=str.casefold)]
listed = names[0] if len(names) == 1 else f"{', '.join(names[:-1])} and {names[-1]}"
lines.append(f"- **{label}**: {listed}.")
return lines
def changelog_url(base: str, tag: str, repo: str = REPO) -> str:
return f"https://github.com/{repo}/compare/{base[:9]}...{tag}"
def closing_lines(thanks: list[str], changelog: str) -> list[str]:
"""The Thanks section, left out when nobody is to thank, and the full
changelog line."""
# Folded: the lists run to a hundred names. GitHub renders the markdown
# inside only with a blank line on each side of it.
lines = (["### Thanks", "", "<details>",
"<summary>Everyone who helped since the start</summary>", "",
*thanks, "", "</details>", ""]
if thanks else [])
return [*lines, f"**Full changelog**: {changelog}", ""]
def render(record: dict, platforms_dir: str, whats_new: str,
closing: list[str] | None = None) -> str:
packs = record.get("packs", {})
folders = _extract_folders(record, platforms_dir)
tag = str(record.get("tag", ""))
lines = [
"### Install in one command",
"### Get your BIOS",
"",
"It finds your setup and its BIOS folder, then downloads only what is missing.",
"Paste this in a terminal. It finds your setup and installs the missing BIOS files.",
"",
"**Linux, macOS, Steam Deck**",
"```sh",
install_command("sh"),
"```",
"",
"**Windows (PowerShell)**",
"**Windows**",
"```powershell",
install_command("powershell"),
"```",
"",
(f"**On a handheld**: copy the `RetroBIOS` file for your system to the card"
f" ([which one]({SITE}wiki/installer/#on-a-handheld-the-retrobios-port))."),
f"**On a handheld**: [get the app for your system]({SITE}{HANDHELD_PAGE}).",
"",
"### Or download a pack",
"### Full packs",
"",
"Extract it into your BIOS folder.",
"Large downloads, for a device without internet. Extract into your BIOS folder.",
"",
]
if any(len(pack["assets"]) > 1 for pack in packs.values()):
@@ -170,6 +283,7 @@ def render(record: dict, platforms_dir: str, whats_new: str) -> str:
*no_pack_paragraph(platforms_dir),
whats_new.strip(),
"",
*(closing or []),
]
return "\n".join(lines)
@@ -182,6 +296,8 @@ def main() -> int:
help="what changed since the previous release, written by hand")
parser.add_argument("--platforms-dir", default="platforms",
help="registry read for each pack's extract folder (default: %(default)s)")
parser.add_argument("--thanks", type=Path,
help="checked Thanks lines to print instead of the ones gh gives")
parser.add_argument("--output", required=True, type=Path,
help="notes file to write, passed to gh release create --notes-file")
args = parser.parse_args()
@@ -201,7 +317,20 @@ def main() -> int:
" changed and how it was checked, not a verdict on other projects or a"
" promise the gap analysis contradicts", file=sys.stderr)
return 1
write_text_atomic(str(args.output), render(record, args.platforms_dir, whats_new))
try:
base = previous_record_commit(record["tag"])
if args.thanks:
thanks = [line for line in args.thanks.read_text(encoding="utf-8").splitlines()
if line.strip()]
else:
thanks = gather_thanks()
except (subprocess.CalledProcessError, ValueError, KeyError, OSError) as exc:
detail = getattr(exc, "stderr", "") or exc
print(f"Error: cannot gather the thanks: {str(detail).strip()}. Pass a checked list"
" with --thanks FILE", file=sys.stderr)
return 1
closing = closing_lines(thanks, changelog_url(base, record["tag"]))
write_text_atomic(str(args.output), render(record, args.platforms_dir, whats_new, closing))
print(f"Wrote {args.output} for {record['tag']}, {len(record['packs'])} packs")
return 0
+258
View File
@@ -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)