diff --git a/ports/shared/tui.txt b/ports/shared/tui.txt index bacc19b7..ecb73063 100644 --- a/ports/shared/tui.txt +++ b/ports/shared/tui.txt @@ -304,9 +304,10 @@ rb_detect() { done < <(rb_py "$RB_EVENTS" status "$RB_STATE/detect.jsonl" 2>> "$RB_LOG") } -# The first pack in RB_PACK_PATH, one whose name spells this system first: -# install.py reads the platform from the same spelling (Name_With_Underscores -# between two underscores). +# The first pack in RB_PACK_PATH whose name spells this system: install.py +# reads the platform from the same spelling (Name_With_Underscores between +# two underscores) and refuses a pack named for another. Any pack only when +# the system is not known, since install.py then reads it from the name. rb_find_pack() { local dir file base stem first="" want="" if [ -n "${RB_PLATFORM_NAME:-}" ]; then @@ -323,13 +324,27 @@ rb_find_pack() { printf '%s\n' "$file" return 0 fi - [ -z "$first" ] && first="$file" + [ -z "$want" ] && [ -z "$first" ] && first="$file" done done [ -n "$first" ] && printf '%s\n' "$first" return 0 } +# How the release pack that serves this system is named: the platforms of its +# group, as packs.txt lists them. build_port.py writes packs.txt from the +# registry's release_pack platforms, so a system with no line has no pack. +rb_pack_label() { + [ -n "${RB_PLATFORM:-}" ] && [ -f "$RB_DIR/packs.txt" ] || return 1 + awk -F'\t' -v p="$RB_PLATFORM" '$1 == p { print $2; found = 1; exit } END { exit !found }' \ + "$RB_DIR/packs.txt" +} + +# Whether the system is known to have no release pack. +rb_no_pack_serves() { + [ -n "${RB_PLATFORM:-}" ] && [ -f "$RB_DIR/packs.txt" ] && ! rb_pack_label > /dev/null +} + rb_same_dir() { [ -n "$1" ] && [ -n "$2" ] || return 1 [ "$(stat -L -c '%d:%i' "$1" 2>/dev/null)" = "$(stat -L -c '%d:%i' "$2" 2>/dev/null)" ] @@ -355,8 +370,14 @@ rb_other_card() { } rb_pack_text() { - local dir="${RB_PACK_PATH%%:*}" pack - if [ -n "${RB_PLATFORM_NAME:-}" ]; then + local dir="${RB_PACK_PATH%%:*}" pack label + if rb_no_pack_serves; then + printf '%s\n' "No network connection, and the releases carry no BIOS pack for ${RB_PLATFORM_NAME:-$RB_PLATFORM}: connect to Wi-Fi and start RetroBIOS again. $RB_PC_ROUTE" + return 0 + fi + if label=$(rb_pack_label); then + pack="the $label BIOS pack (a file whose name starts with ${label}_ and ends in _BIOS_Pack.zip)" + elif [ -n "${RB_PLATFORM_NAME:-}" ]; then pack="the BIOS pack whose name contains ${RB_PLATFORM_NAME// /_}" else pack="the BIOS pack for this system (a file whose name ends in _BIOS_Pack.zip)" @@ -752,6 +773,12 @@ rb_here() { printf 'No connection. Looking for the BIOS folder and a pack on the card...\n' # Detection also names the BIOS folder the other card must not be. rb_detect + if rb_no_pack_serves; then + # Copying from the other card needs a file list too: nothing to ask. + rb_message "$(rb_pack_text)" + printf '1\n' > "$RB_STATE/status" + return 1 + fi other=$(rb_other_card) if [ -n "$other" ] && rb_ask "Copy the BIOS files from your other card? $other holds files. RetroBIOS copies the ones this system needs, each checked by its hash, and leaves that card as it is."; then args=(--from "$other") diff --git a/scripts/build_port.py b/scripts/build_port.py index db703544..b8ca5d67 100644 --- a/scripts/build_port.py +++ b/scripts/build_port.py @@ -46,6 +46,8 @@ MUOS_APP = "RetroBIOS.muxapp" TOOLS_ZIP = "RetroBIOS-tools.zip" # Every archive a release attaches, in the order the release notes list them. ARCHIVES = (PORT_ZIP, BATOCERA_ZIP, MUOS_APP, TOOLS_ZIP) +# Which release pack serves each platform, read by tui.txt offline. +PACKS_TABLE = "packs.txt" _PIN = re.compile(r'^DEFAULT_INSTALL_SHA256="([0-9a-f]{64})"$', re.MULTILINE) @@ -57,7 +59,7 @@ def pinned_installer_sha256(root: Path = REPO_ROOT) -> str: return found.group(1) -def _engine(folder: str, root: Path, mapping: bool = True) -> list[tuple[str, Path]]: +def _engine(folder: str, root: Path, mapping: bool = True) -> list[tuple[str, Path | None]]: """What every launcher runs: the installer, the interface launcher, the event reader, the licenses, and the gptokeyb mapping where one is read.""" shared = root / "ports" / "shared" @@ -67,14 +69,17 @@ def _engine(folder: str, root: Path, mapping: bool = True) -> list[tuple[str, Pa (f"{folder}/rbevents.py", shared / "rbevents.py"), (f"{folder}/licenses/LICENSE-retrobios.txt", root / "LICENSE"), (f"{folder}/licenses/NOTICE-retrobios.txt", root / "NOTICE"), + # Written by build_archive from the registry. + (f"{folder}/{PACKS_TABLE}", None), ] if mapping: members.append((f"{folder}/retrobios.gptk", shared / "retrobios.gptk")) return members -def archive_members(name: str, root: Path = REPO_ROOT) -> list[tuple[str, Path]]: - """(name in the archive, source file) for every member of *name*, sorted.""" +def archive_members(name: str, root: Path = REPO_ROOT) -> list[tuple[str, Path | None]]: + """(name in the archive, source file) for every member of *name*, sorted. + The source is None for the packs table build_archive writes.""" ports = root / "ports" if name == PORT_ZIP: source = ports / "RetroBIOS" @@ -121,6 +126,40 @@ def archive_members(name: str, root: Path = REPO_ROOT) -> list[tuple[str, Path]] return sorted(members) +def release_pack_labels(root: Path = REPO_ROOT) -> dict[str, str]: + """Platform -> the platform part of the release pack that serves it. + + Only the platforms the registry marks release_pack have a pack, grouped + as the release builds them (pipeline.py --include-archived). install.py + refuses a pack whose name does not spell the platform, so a platform + outside these groups has no pack to copy, whatever its file list. + """ + from common import ( # noqa: PLC0415 + group_identical_platforms, + list_release_pack_platforms, + load_platform_config, + ) + from generate_pack import _name_part, _platform_pack_stem # noqa: PLC0415 + + platforms_dir = str(root / "platforms") + pool = list_release_pack_platforms(platforms_dir, include_archived=True) + labels: dict[str, str] = {} + for group, representative in group_identical_platforms(pool, platforms_dir): + stem = _platform_pack_stem(group, representative, platforms_dir) + config = load_platform_config(representative, platforms_dir) + version = config.get("version", config.get("dat_version", "")) + tag = f"_{_name_part(str(version))}" if version else "" + label = stem[: -len(tag)] if tag and stem.endswith(tag) else stem + for platform in group: + labels[platform] = label + return dict(sorted(labels.items())) + + +def _packs_table(root: Path) -> bytes: + return "".join(f"{platform}\t{label}\n" + for platform, label in release_pack_labels(root).items()).encode() + + def build_archive(name: str, output: Path, root: Path = REPO_ROOT) -> Path: """Write the launcher archive *name* to *output* and return it.""" pinned = pinned_installer_sha256(root) @@ -137,9 +176,14 @@ def build_archive(name: str, output: Path, root: Path = REPO_ROOT) -> Path: members = archive_members(name, root) output.parent.mkdir(parents=True, exist_ok=True) scratch = output.with_name(f".{output.name}.tmp") - with zipfile.ZipFile(scratch, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zf: - for member, path in members: - _add_pack_member(zf, str(path), member) + table = output.with_name(f".{output.name}.{PACKS_TABLE}") + table.write_bytes(_packs_table(root)) + try: + with zipfile.ZipFile(scratch, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zf: + for member, path in members: + _add_pack_member(zf, str(path or table), member) + finally: + table.unlink() scratch.replace(output) return output diff --git a/tests/test_handheld_launchers.py b/tests/test_handheld_launchers.py index a387dc04..9475bad8 100644 --- a/tests/test_handheld_launchers.py +++ b/tests/test_handheld_launchers.py @@ -28,6 +28,8 @@ import unittest import zipfile from pathlib import Path +import yaml + REPO_ROOT = Path(__file__).resolve().parent.parent PORTS = REPO_ROOT / "ports" sys.path.insert(0, str(REPO_ROOT / "scripts")) @@ -235,6 +237,21 @@ class TheArchives(unittest.TestCase): second = build_port.build_archive(name, self.out / f"b-{name}").read_bytes() self.assertEqual(first, second) + def test_each_names_the_release_packs(self): + """packs.txt names the platforms a release publishes a pack for, as + the registry marks them, and the pack's name for each.""" + registry = yaml.safe_load((REPO_ROOT / "platforms" / "_registry.yml").read_text()) + released = {name for name, entry in registry["platforms"].items() + if entry.get("release_pack") is True} + labels = build_port.release_pack_labels() + self.assertEqual(set(labels), released) + self.assertEqual(labels["retroarch"], "RetroArch") + expected = "".join(f"{p}\t{label}\n" for p, label in sorted(labels.items())) + for name, (_script, folder) in self.LAYOUT.items(): + with self.subTest(archive=name): + with zipfile.ZipFile(build_port.build_archive(name, self.out / name)) as zf: + self.assertEqual(zf.read(f"{folder}/packs.txt").decode(), expected) + def test_an_unpinned_installer_is_refused(self): root = _tempdir(self) for name in ("install.py", "install.sh", "LICENSE", "NOTICE"): @@ -296,10 +313,12 @@ if "--check" in sys.argv: if os.environ.get("FAKE_DETECT_HANG"): time.sleep(60) print(json.dumps({"event": "detect", "os": "linux", "os_name": "Fake OS", - "platforms": [{"platform": "retroarch", "name": "RetroArch", + "platforms": [{"platform": os.environ.get("FAKE_PLATFORM", "retroarch"), + "name": os.environ.get("FAKE_PLATFORM_NAME", "RetroArch"), "bios_dir": "/roms/bios"}]})) print(json.dumps({"event": "error", "stage": "manifest", "reason": "network", - "platform": "retroarch", "message": "unreachable"})) + "platform": os.environ.get("FAKE_PLATFORM", "retroarch"), + "message": "unreachable"})) print(json.dumps({"event": "end", "code": 1})) sys.exit(1) if os.environ.get("FAKE_REFUSE"): @@ -1249,6 +1268,24 @@ class TheMuosApp(unittest.TestCase): [call] = device.tui_calls() self.assertEqual(call["args"][:3], ["--tui", "--from-pack", str(pack)]) + def test_offline_names_the_pack_that_serves_muos(self): + """muOS runs RetroArch, and install.py reads it as retroarch: the + RetroArch pack serves it.""" + device, app = self.device(muxterm=True) + proc = device.run([str(app / "mux_launch.sh"), str(app)], FAKE_CURL="7") + self.assertEqual(proc.returncode, 1) + # muxterm draws what its child writes: here the launcher's stdout. + screen = " ".join(proc.stdout.decode().split()) + self.assertIn("download the RetroArch BIOS pack (a file whose name starts with " + "RetroArch_ and ends in _BIOS_Pack.zip)", screen) + + def test_offline_takes_no_pack_named_for_another_system(self): + device, app = self.device(muxterm=True) + (device.root / "mmc" / "Batocera_43.1_BIOS_Pack.zip").write_bytes(b"") + proc = device.run([str(app / "mux_launch.sh"), str(app)], FAKE_CURL="7") + self.assertEqual(proc.returncode, 1) + self.assertEqual(device.tui_calls(), []) + def test_offline_reads_a_pack_on_the_second_card(self): device, app = self.device(muxterm=True) (device.root / "sdcard").mkdir() @@ -1445,6 +1482,22 @@ INI # Stopped without the sudo control.ini names. device.assert_mapper_stopped() + def test_rocknix_offline_names_no_pack_the_releases_lack(self): + """No release pack spells ROCKNIX, and install.py refuses one named + for another system: the RetroArch pack on the card is not offered, + and the message gives the computer route.""" + device, tools = self.device(FAKE_CURL="7") + self.rocknix(device) + (tools / "retrobios" / "RetroArch_v1.22.2_BIOS_Pack.zip").write_bytes(b"") + proc = device.run(["bash", str(tools / "RetroBIOS.sh")], stdio_tty=True, TERM="xterm", + FAKE_PLATFORM="rocknix", FAKE_PLATFORM_NAME="ROCKNIX") + self.assertEqual(proc.returncode, 1) + self.assertEqual(device.tui_calls(), []) + screen = " ".join(device.screen.split()) + self.assertIn("the releases carry no BIOS pack for ROCKNIX", screen) + self.assertIn("--dest", screen) + self.assertNotIn("download", screen) + def test_rocknix_start_select_in_foot(self): device, tools = self.device() self.rocknix(device) @@ -1458,6 +1511,21 @@ INI class TheDocs(unittest.TestCase): + def test_the_offline_packs_named_are_the_ones_that_serve(self): + """The wiki names the pack each firmware takes offline: the release + pack of the platform install.py detects it as, or none.""" + labels = build_port.release_pack_labels() + served = {} + for rule in install._EMBEDDED_RULES: + served.setdefault(rule.name, labels.get(rule.manifest)) + for name in ("muOS", "ArkOS", "dArkOS", "JELOS"): + self.assertEqual(served[name], "RetroArch", name) + self.assertEqual(served["Batocera"], "Batocera") + for name in ("ROCKNIX", "KNULLI", "AmberELEC"): + self.assertIsNone(served[name], name) + text = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8") + self.assertIn("The releases carry no pack for ROCKNIX,\nKnulli or AmberELEC", text) + def test_muos_paths_are_the_card_muos_mounts(self): """muOS mounts the first card at /mnt/mmc (MustardOS internal mux_launch.sh, GET_VAR storage/rom/mount).""" diff --git a/wiki/installer.md b/wiki/installer.md index 7aeb67df..7f7298ae 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -987,13 +987,15 @@ running install is then stopped the way B stops it, and the last screen says what it wrote. On muOS without PortMaster, muxterm reads the pad itself: Y ticks a system, Select shows its keyboard, and Menu quits. -**Without Wi-Fi.** On a computer, download the BIOS pack whose name contains -your system (every part, for a pack in parts) and copy it next to RetroBIOS: -into `ports/retrobios` for the PortMaster port and on Batocera, to the root -of either card on muOS, into the `retrobios` folder of the Tools entry. With -no connection, RetroBIOS installs from it, as +**Without Wi-Fi.** On a computer, download the BIOS pack that serves your +system (every part, for a pack in parts): RetroArch's on muOS, ArkOS, +dArkOS and JELOS, Batocera's on Batocera. Copy it next to RetroBIOS: into +`ports/retrobios` for the PortMaster port and on Batocera, to the root of +either card on muOS, into the `retrobios` folder of the Tools entry. With no +connection, RetroBIOS installs from it, as [`--from-pack`](#without-a-network-offline-install) does. With no pack there, -it names the pack and the folder. +it names the pack and the folder. The releases carry no pack for ROCKNIX, +Knulli or AmberELEC: there RetroBIOS needs Wi-Fi, or the computer route. On a two-card device, RetroBIOS without Wi-Fi first asks to **copy the BIOS files from your other card**: muOS's `MUOS/bios` on the card the system does