mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-11 20:43:50 -06:00
chore: merge the launchers served by the site
This commit is contained in:
commit
fba9a0b511
17 files changed
+837
-80
No files matched your search
@@ -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)):
|
||||
|
||||
@@ -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 {})}
|
||||
|
||||
@@ -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...")
|
||||
|
||||
+141
-12
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user